Repository navigation
Conversation
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Test against a wizard branch:
Add Results will be posted here when complete. |
A send of a template stored at the provider now moves by bringing the email into the code: its HTML and text become a file next to the send, the code renders the variables with the provider's escaping, and the site sends the code form. A template made in PostHog, sent by its ID, is used only when the person asks for one. - template-to-code.md: the check, where the email goes, how each language renders it without a new dependency, and the escaping rule. - Steps 3, 7 and 9: the new default, the template form on request, and the report items for emails brought into the code. - SendGrid: Handlebars in the code, including the helpers the template form couldn't take. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send with its own content and legacy substitutions stayed on SendGrid only because SendGrid put the values in. The code now does the same replace before the send, under the escaping rule of template-to-code.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eat/migrate-email-postmark
…eat/migrate-email-mailgun
…migrate-email-amazon-ses
…at/migrate-email-loops
… feat/migrate-email-brevo
…/migrate-email-smtp
…eat/migrate-email-sparkpost
…at/migrate-email-mandrill
PostHog blocks a transactional send only to an address that hard-bounced, so the weekly re-import limits mail to bounced addresses, not complaints. A call that removes an address becomes an "After cutover" item the person acts on when a moved send fails with recipient_suppressed. A call that adds one names the action before the deploy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-import only now names bounces alone, and an app call that adds an address also records it where the moved sends can skip it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A moved send reaches every address an app's add call put on the provider's list, and an app-side flag misses past adds and survives a reactivation. So Step 3 asks once which sites rely on the call, and keeps them on the provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 3 asks which sites rely on the provider's list for addresses added by hand or by the app, and a non-answer keeps every site when the app adds addresses itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the list The suppression-list question gets its own Step 3 section, asks only about sites that would move otherwise, keeps them on a non-answer, and says what would let them move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e work The question now comes right after the field checks, so a site it keeps needs no template copy or PostHog template, and the plan note carries what would let the site move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-rs's async email send waits between retries with tokio::time::sleep and sends through reqwest, so a site on async-std or smol stays on Resend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed d6f261c: 3 Should fix.
This is review round 7 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…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>
…SparkPost sites rethrow The rethrown Error copied only statusCode, name and code, so a catch that moved from Retry-After to the retry delay always took its missing-header path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 9459b57: 1 Should fix.
This is review round 8 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…ed package and adapter The PHP paragraph claimed every API error throws SparkPostException. SparkPost's docs name no response or exception class, and whether an error answer throws can depend on the app's HTTP adapter. The agent now reads the installed package with the app's adapter, names the files in the plan row, and moves a response check to a check of PostHog's error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed eebfe4e: 1 Should fix.
This is review round 9 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…only site moves PostHog makes a text part from the HTML whenever a send has none. SparkPost's docs don't say whether SparkPost did, so an html-only site, inline or from a template, now moves only when the person finds a text/plain part in one email SparkPost sent. Otherwise it stays, as the shared template check does for a provider that sent no text part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…il sent without one A site whose text can be empty kept only html after the move, dropping its own text when set. The send now passes text where it isn't empty, and the person checks an email SparkPost sent without text, since one that carried the site's text says nothing about a generated part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 40c9742: 1 Should fix.
This is review round 10 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
There was a problem hiding this comment.
PostHog Review · Standard · reviewed c77373c: 1 Should fix.
This is review round 11 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…pp can change The tracking, minimization and transactional rules read each account's settings as they are today. An app that updates the account or a subaccount through the API could turn open tracking off later, and that change would no longer reach the moved sends, so such sites stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…site passes them on A site that returned or looped over SparkPost's errors list got PostHog's single message as a string, so consumers that iterate it broke. The message now takes the list's place as a list of one entry of the same kind, and a site that reads other fields of an entry stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… their own code An endpoint answer or a message to another service that carried SparkPost's error objects can have readers of code or description, which a list of one message drops. The person confirms those readers use only message, or the site stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A person who sends with SparkPost and asks to move those sends to PostHog gets no help: no
migrate-emailvariant covers SparkPost.With this PR,
migrate-email-sparkpostmoves SparkPost sends to PostHog's email send in each language where both SparkPost and PostHog have an SDK, andwizard migrate sparkpostruns it. Sends PostHog can't take yet stay on SparkPost, with a reason in the migration report.Important
#472, the core
migrate-emailskill, has merged, and this PR targetsmain. Each provider PR adds its own provider and lands in any order. The lists they all touch,config.yaml,AGENTS.mdand the skill test'sPROVIDERS, conflict in one line each as they land.Impact
Sends that arrive with
migratedFrom: 'sparkpost'show whether people use this skill to move off SparkPost. Read it 14 days after the Wizard runsmigrateas a family.Merge risk
Caution
🔴 Merge risk: high · two-way door until the next context-mill release ships the skill; one-way once agents run it, because a migration's changes persist in each app's code and PostHog account
Tier: critical
migrate-email-sparkpostships with the next context-mill release, and every person who runswizard migrate sparkpostgets it. A wrong instruction in the skill silently changes how the agent rewrites their sends, such as a refusal swallowed, a deadline changed, or an unsubscribed address mailed, in a diff the person may not catch in review.mainshows only the newmigrate-email-sparkpostbundle and its manifest and skill-menu entries.Rollout
migratedFrom: 'sparkpost', as Impact says.Summary
references/sparkpost/holds the three files each variant has:sdk-reference.md: SparkPost's libraries in Node.js (2.x, and the 1.xtransmissionBodyand callback shape with its key renames), Python, Ruby, PHP, Go, Java and Elixir, the community .NET library, the HTTP API, SMTP and framework mailers. It covers how each reports errors and picks the EU host, the template language SparkPost also applies to inline content, stored templates, status reads, webhooks, and the APIs that are not sends.mapping.md:metadata, recipienttags,campaign_id, several recipients or a storedlist_id,start_time,sandbox,skip_suppression, IP pools and DKIM keys,amp_html, A/B tests, raw RFC 822 messages,transactional: false, afromon the sandbox domainsparkpostbox.com, SMTP and framework mailers, and branches on a SparkPost errorcode.override.fromandoverride.reply_towin over the content for inline sends as for templates. Anoverride.fromwithout a name, next to afromwith one, takes the name a received email shows, or the site stays asfrom name not checked: SparkPost's docs don't say which name it shows.options.transactionalon an account whosetransactional_defaultisn'ttruewas non-transactional: SparkPost added itsList-Unsubscribeheader and skipped addresses with anon_transactionalentry, such as unsubscribes. It moves only when the person agrees that it may go without the header and reach people who unsubscribed, now or later. A transactional send on an account withtransactional_unsubalso carried the header, and moves only when the person agrees it may go without it. Otherwise each stays.captureEngagement: PostHog adds its open pixel to every email, and counts no clicks of a send in the code form, as the core's Step 7 says. A site whose emails had no open pixel moves only when the person agrees that PostHog may add one, as the core's Step 3 asks under "Opens the provider didn't track", and a site where nobody can tell stays. A site that moves with PostHog's pixel stays where some addresses revoked open-tracking consent, where the app can write such an entry, or where adata-msys-revoke-trackinglink in mail the account still sends or sent before can write one. Where nobody can check the list or those links, it stays asopen tracking consent not checked. One that tracked opens also stays where the account or subaccount minimizes open events.template-to-code.md. The code renders values,orfallbacks,ifwith SparkPost's truth test,each, snippets and dynamic content, with escaping as SparkPost escaped. Anything the table doesn't place keeps the site, such as a bare name insideeach,loop_index,/, ordata-msys-unsubscribe, and so does a snippet the app creates, changes, or deletes through SparkPost's API, as the core says for a template the app changes. A template made in PostHog, only when the person asks for one, converts to Liquid. Such a template counts clicks unless it is marked sensitive, so the person marks it sensitive where SparkPost tracked no clicks or nobody knows whether it did, as the core's Step 3 asks, and for an email that carries a secret.400or422readsvalidation_error, a branch on420readsquota_limited, a429branch reads status429with a code other thanquota_limited, and a409branch reads both idempotency conflicts,concurrent_idempotent_requestsandinvalid_idempotent_request. A PHP->then($ok, $fail)becomes atryaround the send alone, so an exception in$oknever reaches$fail. Python, Ruby and .NET raisenetwork_erroragain, which SparkPost's error class never caught. A raw HTTP call keeps its deadline, and a site whose limits the SDK can't keep stays, as the core's Step 7 says; the mapping names which SparkPost-era options are deadlines and which limit one stage apart, and leaves axios's and ky'stimeout, library defaults, and Ruby's whole-request and partialNet::HTTPlimits to the core. A PHP site that settles several SparkPost promises together stays, and so does one that stores the transmission id as a number, or passes it to a column shorter than PostHog's 36-character UUID, or of unknown length, or to a service that expects SparkPost's id, unless the person confirms PostHog's id fits. A UUID column takes it. A send the code can cancel moves only where the PostHog send takes the same cancellation. A site that reads a header of SparkPost's answer stays, except aRetry-Afterread, which moves to PostHog's retry delay, as the core's Step 7 and Resend mapping say; Node.js's rethrown error carries that delay. Where a PHP adapter answered a refused send with a response, the site's check of it becomes a check of PostHog's exception.typemaps to a$workflows_email_*event. A handler that matches events bytransmission_id,message_id,rcpt_meta,rcpt_tags,campaign_idortemplate_idkeeps its sites on SparkPost, and so does a handler on an event that has no PostHog event, such asdelay,list_unsubscribeorpolicy_rejection, and aclickhandler unless the site moves to a PostHog template that is not marked sensitive.Manually AddedandCompliance, and SparkPost's calls that add or remove an address, for the core's Step 3 question about sites that rely on the provider's list to stop mailing an address, with the reasonrelies on SparkPost's suppression list. It asks about the list of each account the sites send from. A send right after the app removes its recipient from SparkPost's list stays.account.md: SparkPost's DKIM, bounce and tracking records next to PostHog's, including the clash when the sending domain itself is a bounceCNAME. It also covers the EU host for every request, a read-only key, the account and subaccount tracking, minimization,transactional_defaultandtransactional_unsubsettings, and a template version's content and options, read from the site's own account or a template the primary account shares. The suppression import reads each account's list on its own. It takes bounces, spam complaints, and transactional manual and compliance entries, mapped to PostHog'sreasonvaluesbounce,complaintandmanual, as the core's Step 5 imports them.Evidence
vitest run scriptspasses 344 tests, including the core's check that every "Step N says under" pointer in the SparkPost folder names a real section.node scripts/build.jsbuilds every skill,migrate-email-sparkpostincluded, and leaves the tree clean. The bundle test buildsmigrate-email-sparkpostand checks that every reference path in it resolves.mainat8cd7381, feat(migrate-email): move Resend sends in every PostHog SDK's language #472's squash, which it merges: 288 bundles at the base, 289 at the head. Onlymigrate-email-sparkpostis new, andmanifest.jsonandskill-menu.jsonadd its entries; no other bundle changes. Every shared step in the SparkPost bundle is byte-equal to main's Resend bundle.main.9d8e1fe,8289926) predate the rules that bring templates and inline template language into the code, so they don't cover this head.57d3f73(fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and two team perspectives), fixed in6d13262. Round 2 at6d13262, fixed in93130dd. After the forward merges of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 and the convergence with its shared rules, final reads by fresh-eyes (Opus) and correctness (GPT-6.1 Sol), and from2184cffon by Fleet's single final reader (GPT-6.1 Sol), ran on each fix until5ef962bcame back clean. PostHog Review: 3 should-fix on93130ddand 3 on832fbe3, all fixed; 2 should-fix on115ec8c, fixed ina8d2b65; 3 should-fix onb87374f, fixed incdd83b0; 1 on2184cff, fixed in51148ce; 3 on51148ce, fixed in5ef962b; none on5ef962b. After feat(migrate-email): move Resend sends in every PostHog SDK's language #472 landed andmainmerged in atd6f261c: final read 26 clean; PostHog Review 3 should-fix ond6f261c(response headers, Rubysend_messagefields), fixed in9459b57; final read 27 found 1 high (Node.js rethrow dropped the retry delay), fixed in7df2c7d; PostHog Review 1 on9459b57(PHP error answers), fixed ineebfe4e; final reads 28 and 29 clean; PostHog Review 1 oneebfe4e(plain-text part), fixed in40c9742; final read 30 found 1 high, fixed inc77373c; final read 31 clean; PostHog Review 1 onc77373c(account settings the app can change), fixed in90bd64a, and 1 on90bd64a(errorslist shape), fixed in5852c15; final read 32 found 1 high, fixed inf8204a9; final read 33 and PostHog Review onf8204a9clean.Live run fixtures
Only the live run tests the installed-client reads, so its fixture app holds one site for each:
send_messagecall with positional arguments, and one that also passescontent:orrecipients:.Decisions
captureEngagement. Opens follow the core's Step 3, "Opens the provider didn't track": a site whose emails had no open pixel moves only on the person's yes, otherwiseopen tracking off, and one where nobody can tell stays asopen tracking not checked. Replaced this PR's earlier rule that opens untracked always stays. Every moved site whose clicks SparkPost tracked, or nobody can check, gets a plan note that PostHog counts noneTracking Consent Revokedentries, or the app can write onedata-msys-revoke-trackinglink writes aTracking Consent Revokedentry, so a site that moves with PostHog's open pixel stays when mail its account still sends, or sent before, carries one, as the row above says for the app; where the person can't say, it stays asopen tracking consent not checked. Not chosen: moving on the person's yes for links in old mail, which would waive the row abovetemplate-to-code.md; a template made in PostHog only on requestoptions.transactionalclick tracking offorclick tracking not checked); the core's Step 7 rule that a sensitive email gets no click redirect wins over the Resend wording that a clicked link keeps the site's addresseachbounce,complaintandmanualreasonsPUT /api/v1/accountorPUT /api/v1/subaccounts/{id}call can change stays: a later change would no longer reach iterrorslisttext/plainpart in one email SparkPost sent; SparkPost's docs don't say whether it made one, and PostHog always does. Otherwise it stays withturns the plain-text part offorplain-text part not checkedsend_message's fields and PHP's error answers come from the client installed in the app, named in the plan row; the site stays when it doesn't settle them. SDK source past Fleet's Git allowlist was not readList-Unsubscribeheader moves only when the person agrees it may go without one; PostHog's send takes no headerstimeoutand Ruby's whole-request limits and partialNet::HTTPtimeouts are limits the SDK can't keep, as the core now says. Replaced the folder's own stricter stage-limit rule once the core added onedelay,list_unsubscribeorpolicy_rejection, keeps its site, as the core's Resend mapping doesaccount.mdonly says where SparkPost's list comes from, so the folder needed no changemigrate-email-smtp, which this release doesn't shipInvolvement: qa-swarm, panel rounds 1 (fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and two team perspectives) and 2 (the same, without sweep), thirty-three final reads, and fourteen PostHog Review rounds. Final reads past the sixth each ran with a recorded reason. Earlier rounds on older heads are in the PR history.
🤖 Agent context
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5, high). Reviews on GPT-6.1 Sol (high), the fresh-eyes lens on Opus 5.5.🤖 Generated with Claude Code