Skip to content

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

Merged
Silthus merged 186 commits into
mainfrom
feat/migrate-email-all-sdks
Nov 13, 2026
Merged

Silthus merged 186 commits into
mainfrom
feat/migrate-email-all-sdks

Conversation

@Silthus

@Silthus Silthus commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

migrate-email-resend moves a Resend send to PostHog only when the code is JavaScript or TypeScript. A Python, Go, Ruby, PHP, .NET, Rust, or Elixir backend gets a plan where every site stays on Resend, with the reason "until the skill moves that language". PostHog's SDKs in all eight languages send transactional email now, so those sites can move.

Impact

No metric: PostHog's send API does not record the X-PostHog-Migrated-From header that migrated_from sets, so no insight can count the sends this skill moves, per language or at all. Recording it on $workflows_email_sent would let one insight count moved sends per provider and per organization.

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 and email files, senders, imported suppressions, keys).

  • A person who runs the skill on a Python, Go, Ruby, PHP, .NET, Rust, or Elixir backend: on the base every site stays on Resend; at HEAD the agent rewrites each site PostHog can take into that SDK's send. A send of a Resend template now moves by bringing the email into the code. The rows are uncovered until the owed live run: a wrong rewrite that the person deploys drops an email or changes what it holds, without an error.
  • A JavaScript or TypeScript site changes too: feat(skills): move Resend sends to posthog-node in migrate-email #451's rules, now read from rewrite-node.md, plus the rules this PR adds for every language. A send of a Resend template moves by bringing the email into the code, where feat(skills): move Resend sends to posthog-node in migrate-email #451 moved it only to a template made in PostHog. A site whose emails had no open pixel moves only on the person's yes, and a template made in PostHog asks about Resend's click tracking. Webhook handlers are listed: a site whose handler matches events by email_id or a tag stays, and the report names the PostHog events that replace the rest. A site with its own request deadline keeps it, with no retries, and one that told network failures apart stays. These rows are uncovered until the owed live run: a wrong rewrite changes what an email holds, adds an open pixel the person didn't agree to, sends an email twice past its deadline, or leaves a handler waiting for events that no longer come, without an error.
  • A run that leaves any site on Resend: on the base, the import right before cutover was the last, so an address Resend suppressed after cutover kept getting the moved sends. At HEAD the report asks for a weekly re-import while a site sends through Resend, with a prompt that runs only Step 5's import, and a final one after the last site leaves, which narrows that harm. A run that moves every site is unchanged. The re-import's own rows are uncovered until the live run: a re-import into a project production doesn't send from, when the person confirms the wrong project, leaves production's list stale; and a re-import that switched the MCP to the production project and didn't switch back leaves the person's later MCP work in that project.
  • A run on an app whose sites rely on Resend's suppression list to stop mailing addresses added by hand or by the app: on the base those sites move, and the moved sends reach those addresses, since PostHog blocks a transactional send only to an address that hard-bounced. At HEAD Step 3 asks once and keeps them on Resend, also when nobody answers. Uncovered until the live run: a wrong answer moves a site that relied on the list.
  • Also uncovered until the live run, each a rule that rests on what the person or the app's files tell the agent: secrets and template keys (a send with rendered content that carries a login code or a reset link and misses the SDK's sensitive flag, 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; the report tells the person to keep the moved keys off templates they save later); and the app's runtime (a wrong read of the language version the app runs, or of an edge runtime, installs an SDK release that can't run there, and every moved send in that language fails).
  • Every other skill is unchanged: the shadow diff shows only the migrate-email-resend bundle changes, and every other bundle is byte-identical.
  • Tier: critical: the skill rewrites how apps send transactional email through PostHog Workflows, the critical path of email delivery, where a wrong rewrite drops or doubles sends without an error.
  • Switch-off: none: a revert only stops new runs; a migration an agent already ran stays in that app's code and PostHog account. Nothing changes in an app until its person reviews and deploys the rewrite. Fix forward.

Rollout

Changes

  • One rewrite file per SDK: rewrite-node.md, rewrite-python.md, rewrite-go.md, rewrite-ruby.md, rewrite-php.md, rewrite-dotnet.md, rewrite-rust.md, and rewrite-elixir.md. Each one gives the install, the client with the secret key and migrated_from, the flag-loading setting for a send-only client, both send forms, the result and the error with its code and name, the branch for a suppressed recipient, and the check to run. The names come from the SDK release PRs and match references/sdks.md in the PostHog skill.
  • Step 7 picks the SDK by the site's language from a table, and reads only the rewrite files it needs. Its client, identify, and rewrite rules are the same for every language. The Node.js code moved into rewrite-node.md.
  • Resend's mapping:
    • The language row now keeps only sends in a language without a PostHog SDK that sends email, such as Java.
    • The Rewrite section has a part for each language, mapping Resend's call and answer to PostHog's. The traps it covers: Python's and PHP's error code changes from an HTTP status to a string code, .NET's Guid message ID becomes a string, Elixir's error name becomes an atom, and Resend's Elixir SDK takes no idempotency key.
    • Resend's mailers for Elixir (Swoosh) and .NET (FluentEmail) stay, next to Laravel's and Rails'.
  • Resend's SDK reference lists the client, send, batch, and error shape of each language, so Steps 1 and 3 find them.
  • A template stored at Resend moves into the code (template-to-code.md, from the provider stack): its HTML and text become a file next to the send, rendered with React Email, the app's template engine, or the language's own way, and the send uses the code form. A template made in PostHog is used only when the person asks for one.
  • Every SDK gets the shared Step 7 rules: a client of its own for email, a deadline kept on a client with that timeout and no retries, an abortable signal or cancellable context kept or the site kept on Resend, and a suppressed send handled like a sent one.
  • Suppression list (Steps 3, 5, and 9): Step 3 asks once which sites rely on the provider's suppression list for addresses added by hand or by the app, and keeps them, since PostHog blocks a moved send only to an address that hard-bounced. The import passes PostHog's reason value. An app call that removes an address becomes an "After cutover" item, and the report says which entries don't block moved sends.
  • Steps 1, 3, 8, and 9:
    • Step 1 reads Cargo.toml, mix.exs, and .csproj manifests.
    • Step 3 records each site's language.
    • Step 8 runs the check the rewrite file names.
    • The description and the config no longer say posthog-node.

How it was tested

  • The lane gate on f98eb3b: vitest run scripts, 342 tests in 30 files, and node scripts/build.js pass. migrate-email-skill.test.js keeps the tests that run the built skill: every reference path in the bundle resolves, the transactional email docs and every rewrite file are in the bundle, the step chain walks from 1-detect.md to 9-report.md through the built links, the env key check runs in bash on fourteen env files, each provider bundle holds no provider folder but its own, the Wizard menu lists each migrate command, and in each provider bundle every "Step N says under" reference, the entry's and the report's pointers to Step 5's "Re-import only" included, names a section that step has. The tests that matched the skill's own sentences are gone (QA swarm 2026-11-12).
  • Shadow diff from feat(skills): move Resend sends to posthog-node in migrate-email #451's head a090b5b to f98eb3b: only the migrate-email-resend bundle, manifest.json and skill-menu.json change; every other bundle is byte-identical. Saved in proof storage.
  • Every PostHog name was checked against the SDK sources, and every Resend name against Resend's SDK sources (Python, Ruby, PHP, Laravel, Go, .NET, Rust, and the Hex resend package for Elixir). The QA swarm checked each SDK claim it raised against the SDK sources too.
  • Gaps accepted on 96e10a2, carried to f98eb3b:
    • Live run: an agent run of the migration on a real app needs the released email SDKs and the production send API; Michael's planned trial on generated codebases runs it after both land.
    • CI: build.yml runs only on PRs into main, and this PR's base is feat(skills): move Resend sends to posthog-node in migrate-email #451's branch. The lane gate ran it locally.
    • Prod shape: the inputs are customer-written send code and config and each person's Resend account content; there is no store of real customer codebases to sample. Michael's planned trial on generated codebases covers it after the SDKs land. The events Step 8 queries are produced by PostHog's send pipeline, checked against its source; only the live run on a real app exercises them.

Review

  • Round 1 (Opus 5.5) found 2 major and 5 minor issues, and all of them are fixed in c4ed38c:

    • Each SDK's check now covers the whole project, and a major upgrade reads the changelog and searches for the app's other SDK calls.
    • The client snippets read the env without raising, so a missing key can't stop the app from booting.
    • Sync and async clients run side by side in Python.
    • Rust and Ruby upgrades move the version requirement.
    • go mod tidy runs after the rewrite.
    • Resend's status error classes map to a status check.
    • Edge runtimes use posthog-node.
  • Round 2 (GPT-6.1 Sol) found 1 issue, fixed in a658c75: posthog-rs builds either the async client or the blocking one, so the Rust file uses the one the app's features build.

  • The final read on a658c75 found nothing.

  • 6ab1cc2 merges the migration lane's per-SDK rules, from feat(skills): move SendGrid sends to PostHog in every SDK language #459's shared SDK reference, so the rewrite files are the only place for them. Each language now covers its network error after retries and its bad-argument error, plus .NET's lack of a synchronous send. Checked against the current SDK PR heads in f6b6af8:

    • No release takes cc, bcc, or attachments yet, so they stay on the provider.
    • Go and Ruby load flag definitions from their host with the secret key, so behind a proxy email gets its own client on PostHog's host.
    • PHP gets its own email client and leaves PostHog::init alone.
  • Final reads on the merge found three Elixir problems, all fixed. Email gets its own named instance when the app already uses PostHog (ef2c2e9). A missing host or token can no longer stop the app from starting (fbd1e83), and neither can one the app configures as nil (78d56ce). The final read on 78d56ce found nothing.

  • bb711c5: Step 7 lists the ten error names once, and each rewrite file points there instead of at a provider mapping. Each rewrite file also says whether reply-to takes one address or a list, checked on each SDK head. The final read on bb711c5 found nothing.

  • 5398fd9: behind a reverse proxy, email gets its own client on PostHog's host, for every SDK, in place of the Go and Ruby special cases. A client with the secret key loads flag definitions from its host with it: Go and Ruby when they start, and Python and .NET when the app evaluates a flag (checked on the SDK heads). A final read on the stack found this. 5398fd9 has no final read of its own yet, because feat(migrate-email): move Resend sends in every PostHog SDK's language #472 is past its sixth.

  • 6924d48: a GPT-6.1 Sol final read on 5398fd9 (the 7th, on Michael's word) found the proxy test matched only the separate-client wording. The test now also asserts "leave the secret key off the app's existing client": red with that clause removed, green with it (250 tests).

  • 2ebcce0: a send of a template stored at Resend now moves by bringing the email into the code, on Michael's direction for every provider: its HTML and text become a file next to the send, the code renders the variables, and the send uses 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 comes from the provider stack as is, and Steps 3, 7 and 9 carry the same change. Resend never escapes a value, so the shared escaping rule applies. A fallback covers a missing value only. A from, reply-to or subject that the site passes wins over the template's, as Resend does. Five new tests fail on 6924d48 and pass here (254 tests); the build passes.

  • 6b41f33: the shared step changes from #459 move here, so each provider PR adds only its own provider. The steps no longer assume Node.js, keep each moved site failing the way it did, and list the app's webhook handlers and suppression writes for the report. feat(skills): move SendGrid sends to PostHog in every SDK language #459's two Resend shadow runs with these steps classified every site the same.

  • QA swarm 2026-11-12, on 82d6ebf with feat(skills): move Resend sends to posthog-node in migrate-email #451's fixes merged in (22df812):

    • Round 1 (Claude Opus 5.5 fresh eyes, GPT-6.1 Sol on six lenses and five team lenses): fixed in e36fd64 and 08c9d15. .NET gets a PostHogClient of its own for email, since AddPostHog() registers IPostHogEmails from the app's client. In Go and .NET, a deadline longer than the SDK's request timeout needs a client with that timeout. The Rust blocking client isn't awaited. Elixir always uses a named instance. A send in the code form is sensitive in PostHog and counts no clicks, so a site whose clicks feed a handler moves only to a non-sensitive PostHog template. Temporary template copies are deleted on every way out. A template the app changes at Resend stays. A Go link value with another scheme, such as tel:, stays. The tests that matched the skill's wording, and an it.each that ran no case, are deleted. Rejected: a missing token crashing Node.js and Ruby (both SDKs disable the client instead), the React Email rebuild (an app on React Email edits its emails as components), and prod shape (PostHog stores no person's code), now recorded as a gap.
    • Round 2 on the fixes, and ReviewHog round 1: fixed in bb14da9. A blank .NET host falls back instead of throwing, a .NET deadline over 100 seconds stays (its HttpClient default), a shared template's copy waits for its last site, a stored message ID points to the suppressed-send rule, and an error handler that also covers a call staying on Resend keeps Resend's handler. ReviewHog round 2 on bb14da9: a Go template that fills CSS from a value, such as rgb(255, 0, 0), gets ZgotmplZ from html/template (checked in Go 1.23), so such a site stays; fixed in eeeb620. ReviewHog round 3 on eeeb620: the report's "Before cutover" check now names a template brought into the code, and the Python email client sets enable_local_evaluation=False, so a worker forked under a preloading server doesn't poll flags with the email key; fixed in 64bb2a2. A final read on 64bb2a2 found that AsyncPosthog has no such option and raises TypeError on it; fixed in 8ead8e1. Final reads on bb14da9, eeeb620 and 8ead8e1 found nothing.
    • Relayed from the provider lanes and settled here: per-SDK timeout and retry options under "A deadline" in each rewrite file, with one shared rule in Step 7; Go's context and .NET's token carry the deadline and cancellation; Rust takes whole seconds; Node.js has no signal. A lost template copy comes from the dashboard, since the full-access key is gone after Step 5. An unquoted attribute value is quoted before an escaped value goes in. Resend's mapping lists the calls that change its suppression list. A branch on one kind of network failure, such as ECONNRESET or requests.Timeout, keeps its site on the provider, since every SDK reports one network_error; a kept deadline's own timeout still matches in Go and .NET. A limit on connecting alone, or apart from reading, keeps its site too, except in Go, whose email client takes the request's transport. Fixed in 0158aef, f6978e7 and ad23a92, with Resend's Node.js application_error for a network failure now also taking network_error.
    • ReviewHog round 4 on 0158aef: posthog-php sends email through curl, which Composer doesn't ask for, so the app requires ext-curl and the report lists enabling it where that isn't shown; fixed in f6978e7. A final read on f6978e7 found that Go keeps transport limits; fixed in ad23a92. ReviewHog round 5 on ad23a92: posthog-python hands its timeout to requests or httpx as one number, which limits each network wait, so an aiohttp total timeout keeps its site on the provider; fixed in 7ccc0ee. The reads on ad23a92 and 7ccc0ee, run under the standing review rule past Fleet's cap, found nothing.
    • Relayed from the provider lanes, on Michael's call to fix both now, fixed in eef1481: a site whose provider didn't track its clicks, or where nobody knows, moves to a PostHog template only when the person marks it sensitive, since PostHog sends a non-sensitive template's links through its click redirect; Resend's account file says where its per-domain click tracking shows. A library's default timeout, such as httpx's or ky's, is not the site's deadline. Every SDK's timeout limits the whole request except Python's and Ruby's, which limit each network wait, and a limit of another kind keeps its site on the provider, such as axios's idle timeout and ky's, which ends once the headers arrive. Resend's sensitive template sends its links without the click redirect. The message ID is a UUID, so .NET code that keeps a Guid takes Guid.Parse. A final read on eef1481 found that Faraday's timeout limits each wait only on its net_http adapter; fixed in efb8451. ReviewHog round 6 on efb8451: a handler that acts on a Resend event with no PostHog event, such as email.delivery_delayed, keeps its sites on Resend; fixed in f658354. Final reads on efb8451 and f658354, under the standing review rule, found nothing.
    • Step 8's tests, on Michael's call, merged from feat(skills): move Resend sends to posthog-node in migrate-email #451 in ced460c: a test that mocked the provider's client runs the real PostHog SDK and intercepts PostHog's send request, asserting on that request and on what the app does. Node.js, Python and Ruby intercept in process, with msw, nock 14 or later, undici's MockAgent or a fake global fetch, responses, requests-mock, respx or pytest-httpx, and webmock. Go, .NET, PHP, Rust and Elixir can't swap the SDK's HTTP client from a test, so the test runs a local server and points the email client's host and app host at it, through an optional app host variable; Go and .NET allow an http app host only for a loopback address (d51979c). No test tool is added as a dev dependency without asking the person once, in any language. Node.js, Go, .NET and PHP need none; in Python, Ruby, Rust and Elixir, on a no, the test runs a local server from the standard library, such as Python's http.server or Ruby's TCPServer, and no test keeps a mock of the send (6f7abcb, 8776c81, 63c3dfe). An earlier version kept the mock on a no; final read 12 found it, and the judge on 96e10a2 failed its rejection. Also relayed from the provider lanes: a Ruby request that sets only some of Net::HTTP's timeouts, or sets them apart, stays, since posthog-ruby sets all three to one number (e14c1fc); Step 3 writes must be marked sensitive into a template's plan note, so the report checks it for every provider (a943460); and a branch on the name service_unavailable skips network_error, which every PostHog SDK also names so, so a network failure takes the path the old code gave it (45c5716). A final read on a943460 and ReviewHog round 7 found the http app host point; a final read on d51979c, under the standing review rule, found nothing.
    • A recurring suppression re-import, on Michael's call, after the SendGrid and Mandrill judges found that a site that stays keeps adding bounces and complaints PostHog never sees (21e1d27). Step 5 gains "Re-import only", which runs the import alone with no plan file, and the skill entry points to it, so the person re-runs only the import. It first asks the person whether production sends from the MCP's project, with a differing local token as a hint, since the local env file may name a development project; on a no it offers to switch the MCP to the production project and switches back when the step ends, and it stops when they can't tell or the project isn't in the organization's list. With no import tool it tells the person where to add the new addresses by hand, naming the production project, and says so instead of a count, as it does when it couldn't get the list (a65bb90, b28e13b, b2dfe53, 731b092, 8929f93, e5735c8, 8b9757e, 349a407). Steps 5, 7 and 8 now read the project token with project-get, the one MCP tool that returns it; projects-get lists projects without their tokens (731b092). When any site stays, the report asks for a weekly re-import while a site sends through the provider, gives the prompt, says a repeat is safe, and asks for a final import after the last site leaves. Weekly, not the release cadence, since the sites that stay add entries between releases. A provider's account.md only says where its list comes from.
    • A provider that keeps no suppression list of its own, such as a mailbox provider behind an SMTP host, gets no weekly re-import ask: Step 5 records empty list: <provider name> keeps no suppression list before it checks the import tool, and the report leaves the ask out (d8dd2d4, 94d44d3), relayed from the SMTP lane.
    • A re-import alone, for a provider that keeps no suppression list, starts with the provider's notes and stops there, telling the person there is nothing to re-import, before it reads the env file or asks about the project (2b8d0d6, 5f5debd), relayed from the SMTP lane's judge.
    • Untracked opens, on Michael's call (17c6ca1): PostHog adds its open pixel to every email it sends, so Step 3 asks the person once, for all sites whose emails had none, whether PostHog may add it. A yes moves them, with a plan note the report names; a no or no answer keeps them 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 new bundle test fails when a "Step N says under" reference names a section the step lacks (red with the new heading renamed). A final read and ReviewHog round 8 on 17c6ca1, under the standing review rule, found nothing.
    • 96e10a2 merges feat(skills): move Resend sends to posthog-node in migrate-email #451's cff58f5, which brings this PR's network-failure rule for Resend down to feat(skills): move Resend sends to posthog-node in migrate-email #451; this tree already had it, so the merge changes no file.
    • 090ac92 merges feat(skills): move Resend sends to posthog-node in migrate-email #451's 2df1f26, which names project-get for the project token in Steps 7 and 8; this tree already had it, so the merge changes no file.
    • cb26a5f merges feat(skills): move Resend sends to posthog-node in migrate-email #451's eeed7cb, which lists Resend webhook handlers and keeps the sites they act on, and states the SDK's limit for sites without a deadline. This tree already had both in its own words, so the merge keeps feat(migrate-email): move Resend sends in every PostHog SDK's language #472's text there, and carries feat(skills): move Resend sends to posthog-node in migrate-email #451's Step 8 check of each error in a rewritten line against its old version. e7a7f3f merges feat(skills): move Resend sends to posthog-node in migrate-email #451's 77b680b, which gives Step 8 one rule for which errors are new, as final read 30 asked; feat(migrate-email): move Resend sends in every PostHog SDK's language #472's own text takes the same rule.
    • 6c45e23 merges feat(skills): move Resend sends to posthog-node in migrate-email #451's 2fe8430, keeping feat(migrate-email): move Resend sends in every PostHog SDK's language #472's text, and 4087e6c carries feat(skills): move Resend sends to posthog-node in migrate-email #451's newer fixes in this tree's form, one rule for every SDK: Step 7 asks the person once whether a template saved in PostHog already has any of the moved sends' keys and gives such sends another key, since such a send takes that template's settings and fails when it isn't transactional (the ReviewHog P1 on 7-rewrite.md, where a code send was called sensitive whatever its key); a send with rendered content that carries a login code, a reset link or another secret sets the SDK's sensitive flag; and before an SDK install or upgrade, Step 7 checks the language version the app runs against what the 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 its rule covers warnings (final read 31). Final read 32 on 4087e6c found that no rewrite file named the sensitive flag; each SDK's latest release takes it, checked in its package, and each rewrite file's "The send" now names it (808f61d), with the moved template keys in the report and an edge runtime's check. Final read 33 moved the keys to "After cutover" and gave an unnamed edge runtime its own reason (fd58f93). ReviewHog on 808f61d and fd58f93 found nothing. Final reads 34 to 36 scoped the runtime check to the dependency manifest the SDK goes into (f1ba031, a0b01bc, 8f51a57, 6b0083e): when the release doesn't support a server whose build installs that manifest, directly or through a workspace or a root lockfile install, every moving site on it stays, an edge site too, since the install would reach that server; only otherwise does an edge site get its own check. The report's key note names the click redirect and the subject on engagement events too.
    • ReviewHog round 13 on 6b0083e: a request with its own deadline whose HTTP client also retried it would lose those retries on the move. Such a site now stays (ef71e1f), counted only when the retry setup covers a POST, as final read 39 asked (6fdfdf7), with the setups named as examples (6d4801f). The agent reads the setup's options, since axios-retry and urllib3's Retry retry a POST only at their defaults (caff403), and a requests adapter with a number as its max_retries counts too (7aad014), from the gate judge and final read 42. Final reads 40 to 43 and ReviewHog rounds 14 to 16 found nothing.
    • 66dc2b2 merges feat(skills): move Resend sends to posthog-node in migrate-email #451's a090b5b, which is 2fe8430 plus main's omnibus and commandments files, so the merge brings only those. ReviewHog on a090b5b found that a site reading Resend's response headers, such as retry-after, moved although PostHog's answer carries no headers. Such a site now stays, unless it reads only the retry delay of a refused send, which moves to the PostHog error's (6d3d8b0). Each rewrite file names its SDK's retry-delay field and unit under "The answer", checked against each SDK's latest release, and a site that keeps its own deadline keeps its own wait and retry (610e703, from final read 44). ReviewHog on 610e703: a Resend template whose text is an empty string sends no text part, which PostHog can't do, since it makes one from the HTML; such a template, and a code send with text: "" beside html, now stay (d186900). Final read 46 widened it to react and to a text the code builds that can be empty, and the shared template rule now keeps any template whose provider sent its emails with no text part (088f35b). Final read 47: that rule now sits in the part Step 3 reads, and an empty Text in Go moves, since Resend's Go SDK leaves it out of the request (3b204b7). Final read 48: a dashboard copy can't tell an empty plain text from a missing one, so the run asks, or offers the full-access key to read that template from the API, keeps the site when neither settles it, and every template's note records its plain text, so Step 7 never asks again (f3bb668, 355df95, 74040e5).
    • ReviewHog on 74040e5: Step 5's rule for an app's calls that change Resend's suppression list keyed on reason text PostHog never stores. PostHog writes its own reason, such as Imported (bounced), takes only bounce, complaint or manual, keeps no source an app could check, and blocks a transactional send only to an address that hard-bounced. So the app no longer syncs these calls (b99cea5 to 4db6763, over final reads 52 to 59 and ReviewHog on 9eb4180). The import passes the reason value. Step 3 asks once which sites rely on Resend's list for addresses added by hand or by the app, before any template work, and keeps them, also on a non-answer. A call that removes an address becomes an "After cutover" item for when a moved send fails with recipient_suppressed. The report says that an address that complained or was added by hand gets the moved sends, so the weekly re-import limits mail only to bounced addresses.
    • ReviewHog on 4db6763: posthog-rs's async email send waits between retries with tokio::time::sleep, so a Rust site whose async code runs on another runtime, such as async-std or smol, or on a Tokio runtime built without timers, stays (0a6ae00, f98eb3b).

Decisions

Question Answer Who Where
A stored Resend template Moves into the code by default; a template made in PostHog only on request 👤 Human this PR, 2ebcce0
Email client per SDK Always one of its own, never the app's: .NET builds a PostHogClient, Elixir a named instance 🤖 Agent QA swarm 2026-11-12
A deadline Kept per SDK with its timeout option and no retries; Go and .NET pass it on the send and raise the client timeout when it is longer; a deadline the SDK can't hold stays 🤖 Agent QA swarm and provider lanes 2026-11-12
A deadline on a request whose HTTP client retries a POST The site stays on Resend: the send that keeps the deadline takes no retries; the agent reads the setup's options, and one that skips a POST keeps the deadline 🤖 Agent ReviewHog round 13 and final read 39, 2026-11-13
A site that reads Resend's response headers Stays, unless it reads only the retry delay of a refused send, which moves to the PostHog error's retry delay; PostHog's answer carries no headers 🤖 Agent ReviewHog on #451's a090b5b and final read 44, 2026-11-13
An app's calls that change Resend's suppression list Not synced to PostHog, which keeps no source an app could check; Step 3 keeps the sites that rely on the list, also on a non-answer, and a removal becomes an "After cutover" item; replaces the synced-reason rule 🤖 Agent ReviewHog on 74040e5 and final reads 52 to 58, 2026-11-13
Complaint and hand-added entries reaching moved sends Stated in the report: PostHog blocks a transactional send only to an address that hard-bounced, by design, and no per-address action changes that 🤖 Agent final read 54, 2026-11-13
A send or template whose text is an empty string Stays on Resend, also beside react or when the code builds a text that can be empty: it sends no text part, and PostHog makes one from the HTML; a send without text moves 🤖 Agent ReviewHog on 610e703, 2026-11-13
Clicks on a code-form send None: PostHog treats it as sensitive, since Step 7 gives it a key no saved template has; a site whose clicks feed a handler moves only to a non-sensitive PostHog template 🤖 Agent QA swarm 2026-11-12
Tests Only tests that run the built skill; no tests that match its sentences 🤖 Agent QA swarm 2026-11-12
A branch on one kind of network failure Stays on the provider, except a kept deadline's timeout in Go and .NET 🤖 Agent provider lanes 2026-11-12
Suppressions added by sites that stay Re-import the list weekly while any site sends through the provider, with a prompt that runs only Step 5's import, and once more after the last site leaves; unchanged when every site moves 👤 Human conductor relay 2026-11-12
The MCP tool that reads the project token project-get, called without an ID, the one tool that returns api_token; replaces projects-get, which lists projects without it 🤖 Agent final read 22, 2026-11-12
The weekly re-import ask for a provider with no list Left out when the plan says the provider keeps no suppression list 🤖 Agent SMTP lane relay 2026-11-13
A re-import alone for a provider with no suppression list Ends before the project check, with nothing to re-import 🤖 Agent SMTP lane relay 2026-11-13
Step 8 tests of a mocked provider client Run the real PostHog SDK with PostHog's send request intercepted, and assert on that request and on what the app does; replaces keeping the test at its mock level 👤 Human conductor relay 2026-11-12
No interception tool in the suite Every language asks before adding a test tool; Node.js, Go, .NET and PHP need none, and Python, Ruby, Rust and Elixir run a standard-library server on a no. Replaces keeping the mock on a no, which was the conductor's wording, not Michael's 🤖 Agent conductor, after the judge on 96e10a2, 2026-11-12
Opens the provider didn't track Ask once whether PostHog may add its open pixel; yes moves the sites, no or no answer keeps them as open tracking off, unknown tracking keeps them as open tracking not checked 👤 Human conductor relay 2026-11-12
Clicks the provider didn't track A site moves to a PostHog template only when the person marks it sensitive; otherwise it stays 👍 Accepted default conductor relay 2026-11-12
A library's default timeout Not the site's deadline: the site sends with the PostHog SDK's own timeout and retries 👍 Accepted default conductor relay 2026-11-12
A limit of another kind than the SDK's timeout Stays on the provider: axios's idle timeout, ky's, and a whole-request limit in Python or Ruby 🤖 Agent provider lanes 2026-11-12
A handler on a Resend event PostHog lacks Its sites stay on Resend 🤖 Agent ReviewHog round 6
A Ruby request with some of its Net::HTTP timeouts Stays: posthog-ruby sets open, read and write to one number 🤖 Agent provider lanes 2026-11-12
A Rust site on an async runtime other than Tokio Stays, also on a Tokio runtime built without timers: posthog-rs's async send waits between retries with Tokio's timer 🤖 Agent ReviewHog on 4db6763, 2026-11-13
A template that must be marked sensitive Step 3 writes it into the template's plan note, so the report checks it for every provider 🤖 Agent provider lanes 2026-11-12
Secret emails sent from code The SDK's sensitive flag on a send with rendered content that carries a secret; each rewrite file names it, and a release without it keeps the site 🤖 Agent ReviewHog P1 and final read 32, 2026-11-13
Template keys a saved template already has Ask the person once and give such sends another key; the report lists the moved keys after cutover, for templates saved later 🤖 Agent ReviewHog P1 and final read 33, 2026-11-13
The app's runtime version Checked before the SDK's install against every server that installs from the manifest it goes into; one unsupported server keeps every site on that manifest, and an edge runtime the rewrite file doesn't name keeps its own sites 🤖 Agent #451's ReviewHog and final read 32, 2026-11-13
A workspace server whose package manager only warns on engines Still keeps the sites on the SDK's manifest: a kept site loses nothing, and misreading the package manager's strictness breaks a deploy 🤖 Agent final read 38, 2026-11-13
React Email apps Keep the component rebuild 🤖 Agent QA swarm 2026-11-12
Prod shape Owed, recorded as a gap: the inputs are customer-written code, config and each person's Resend account, and no store of real customer codebases exists to sample; Michael's trial on generated codebases covers it 🤖 Agent Fleet judge 2026-11-12

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 sixty-one final reads, the two-family ones with their points fixed or declined with a reason; forty-six ReviewHog reviews, every finding fixed and the last two clean; relayed findings from six provider lanes settled. Proof captured: shadow diff; live run, CI and prod shape owed as gaps.

Merge

Stacked on #451. It ships with the eight SDK releases, like #450 and #451, and merges after them. The provider PRs, #459 to #468, each stand on this one alone and land in any order after it. Retarget them to main before this merges: deleting this branch on merge would close them.

🤖 Generated with Claude Code

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

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

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

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

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

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

The send-transactional-email skill is gone from this stack, so migrate-email
bundles the transactional email pages and names the sending-transactional-emails
skill for the general move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… PostHog's reply-to rules

The preflight looked for two MCP tools that don't exist. It now looks
for the make-transactional pair, and a missing pair records the API as
not confirmed instead of stopping, because it can also mean a missing
scope. The mapping now keeps a site on Resend for reply-to addresses
with a name or more than 10, and lists every reserved header and the
header and tag length limits.

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

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed f98eb3b: no findings.

@Silthus
Silthus changed the base branch from feat/migrate-email-resend-cutover to main November 13, 2026 16:19
main equals #451's merged head a090b5b, which this branch already contains, so the tree is unchanged from f98eb3b.

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

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 418cc5a: no findings.

@Silthus
Silthus merged commit 8cd7381 into main Nov 13, 2026
17 checks passed
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
Map each SendGrid list to PostHog's reason, name Global Unsubscribes for
Step 3's question about sites that rely on the suppression list, and
replace "Suppression writes" with the calls section Step 3 and Step 5
read, without the import's entry rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
…ules

- account.md maps each SuppressionReason to PostHog's reason: bounce,
  complaint, or manual. It says PostHog blocks a moved send only to a
  bounce entry, and names how an address gets onto the outbound list
  for Step 3's question on sites that rely on Postmark's list.
- mapping.md drops which suppression-list calls count, which Step 5 no
  longer reads, points those calls at Step 3 too, and says PostHog
  refuses only a hard-bounced address with recipient_suppressed.
- A send without a text body notes that PostHog makes the text part,
  and code that reads a header of Postmark's answer stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
- account.md maps each SparkPost source to PostHog's reason (bounce,
  complaint, manual), and says only a bounce blocks a moved send.
- mapping.md feeds Step 3's new suppression question: the Manually Added
  and Compliance entries and the app's suppression writes, per account,
  with the reason `relies on SparkPost's suppression list`.
- mapping.md drops the "which entries count" text Step 5 no longer reads.

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

BOUNCE imports as bounce and COMPLAINT as complaint. PostHog refuses a moved send only for a bounce entry, so the widening checks count only BOUNCE entries. Name where Amazon SES entries are added by hand for Step 3's question, and drop the entries-that-count text Step 5 no longer reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
…t rules

Map each Rejection Denylist entry to PostHog's reason, drop the "which
adds count" text Step 5 no longer reads, and name the Rejection Denylist
and its custom and unsubscribe entries for Step 3's new question. PostHog
now makes a text part whenever a send has none, so an HTML send that
Mailchimp Transactional sent with no text part stays, and the Sending
Defaults' plain-text box joins the account questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
…472

Map Bounces, Complaints and Unsubscribes to PostHog's bounce, complaint
and manual reasons, and leave out allowlists. Feed Step 3's
suppression-list question with Mailgun's unsubscribes, which also stopped
transactional sends. Drop the unsubscribes report count and the "which
entries count" text that Step 5 no longer reads, and say that PostHog
blocks a moved send only to an address that hard-bounced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
…-email-track-opens-and-clicks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
main's tree equals #472's final head 418cc5a, merged in the parent commit, so this merge takes no content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
…y send_message fields from the installed gem

A site that reads a header of SparkPost's answer, other than the retry
delay, now stays, as Resend's does under #472's Step 7. A Retry-After
read moves to the PostHog error's retry delay. A Ruby send_message
call maps the fields the installed gem sent, where a keyword can
override a positional argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
Silthus added a commit that referenced this pull request Nov 13, 2026
…Step 3's list question

Follow #472's shared steps: each entry on the provider's list maps to a
PostHog reason (bounce, complaint, manual), and Step 3's question about
sites that rely on the list covers only sites whose host's provider
keeps one, naming that provider in the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
#472's template check keeps a template the provider sent with no text
part. A Brevo template has no text part of its own to turn off, and
Brevo makes a plain text version of HTML emails itself, so the check
doesn't keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit that referenced this pull request Nov 13, 2026
…og's retry delay

A Loops retry loop that waited for Retry-After now reads the retry
delay PostHog's error carries since #472, converted to the loop's unit,
and keeps its own path only when PostHog named no wait.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants