Repository navigation
Conversation
Adds the sparkpost variant with its SDK reference, field mapping, and account notes: cc and bcc as header_to recipients, inline content that SparkPost runs through its template language, templates to Liquid, the msys webhook events, and the suppression list by source. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- headers, metadata, recipient tags and campaign_id always stay: no PostHog SDK takes headers or tags yet. - if converts only for booleans, each only for lists; tracking options next to template_id keep the site. - Go and Elixir decode base64 attachments; contentId is a bare name. - A from name with special characters is quoted. - Narrow catches stay narrow in every language, and Python and Ruby re-raise network_error where SparkPost's class never caught it. - EU accounts read templates and suppressions from api.eu.sparkpost.com. - Drop claims about PostHog the shared files don't make. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…arkpost # Conflicts: # context/skills/migrate-email/config.yaml # scripts/lib/tests/migrate-email-skill.test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A branch on SparkPost's 400 or 422 becomes a branch on PostHog's validation_error name in every language; Node.js carries error.name. - A bare name inside each keeps the template: SparkPost reads the item through loop_var, and its docs don't say what a bare name reads. - sdk-reference names each SDK's host setting as the EU signal, and account.md says up front which host every request uses for it. - The client part says framework mailers serve other providers and always stay. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
🧙 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. |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Suppressions: read every source and type, so left-out entries can be counted, and read each sending account on its own: an unscoped search can be 20 minutes out of date. - Templates: a stored version's tracking and transactional options apply unless the send overrides them; the dashboard intake asks for them, the AMP part and custom headers. - Cc and bcc stay unless every recipient carries the same values. - Inline values become strings before escaping; subject and reply_to follow the same rule as from; subjects are flagged for a test send. - 420 maps to quota_limited; .NET re-raises network_error like Python and Ruby. - Recipient variables avoid names the send already uses. Co-Authored-By: Claude Opus 5.5 <[email protected]>
SparkPost takes only keys of 1 to 255 letters, digits, '.', '_' and '-', so the bounded-key rule never keeps a SparkPost site. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…rewrite throws The Node rule rethrew PostHog's error with only its status, and a network failure has none, so the old catch lost the code that says the request never reached PostHog. Carry error.code as well. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Name SparkPost's sandbox domain sparkpostbox.com: a from on it keeps
the site, since no sender can be verified for it.
- Cover sparkpost 1.x: transmissions.send({ transmissionBody }, callback)
with camelCase keys, the (err, res) callback and res.body.results.id.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ited, subaccount lists, 1.x key renames Co-Authored-By: Claude Opus 5.5 <[email protected]>
…es, and check ky's throwHttpErrors Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ng with the template's, and mirror suppression removals Co-Authored-By: Claude Opus 5.5 <[email protected]>
…removal check Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ome addresses revoked open-tracking consent Co-Authored-By: Claude Opus 5.5 <[email protected]>
…he account or subaccount minimizes open events Co-Authored-By: Claude Opus 5.5 <[email protected]>
…en-event minimization, also one that sets its own tracking Co-Authored-By: Claude Opus 5.5 <[email protected]>
…on setting takes the account's Co-Authored-By: Claude Opus 5.5 <[email protected]>
… the subaccounts that take it Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ly_to with perform_substitutions off, and a recipient's minimization keeps a site Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…l-sparkpost Brings #472's rewrite-<language>.md files through the layer below. posthog-sdks.md is gone; this provider's mapping moves to the rewrite file in the next commit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every posthog-sdks.md reference now points at the rewrite file for the site's language. Cc and bcc recipients, attachments, and inline images now always stay on SparkPost, because no PostHog SDK takes them yet. Their conversion rules are gone, and the Cc and bcc section only says how to spot them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…arkpost Brings #472's Elixir fix (fall back past a nil :posthog host) and each lower provider's move to the rewrite file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings #472's single list of error names in Step 7 and the reply-to rule in each rewrite file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings #472's Step 7 rule: behind a reverse proxy, email gets its own client on PostHog's host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…eat/migrate-email-sparkpost
A stored SparkPost template now moves by bringing its email into the code, rendered with the site's substitution_data, the recipient's values first. Inline content that uses SparkPost's template language moves the same way, where it used to stay. In the code, raw values, conditions, loops, nested loops, empty(), lengths, whole-number arithmetic, array indexes, dynamic content, snippets the code can pick, brace macros, and URL-encoded link values now move. A template made in PostHog is used only when the person asks for one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…-email-sparkpost # Conflicts: # scripts/lib/tests/migrate-email-skill.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Important
Stacked on #467. Merge that first. This PR's own changes:
e1a4d44...ee9e706.It adds one provider folder, one config variant, one
AGENTS.mdrow and the provider in the bundle test. The shared steps it uses, and their review fixes, come from #459.Like #459, it waits for the PostHog SDK releases with the code-form email send. Nothing merges until code sends are live and usable for customers: Michael's rule for #451 and every PR stacked on it.
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
Warning
🟡 Merge risk: medium · two-way door
wizard migrate sparkpost, so reach is few. Then the skill edits their send code, and a wrong rewrite harms silently once they deploy: a refusal swallowed, open capture started for addresses that revoked it or on an account that minimizes open events, an inline value rendered unescaped, or a suppressed address mailed. Each moved site keeps its error behavior and its tracking, a site stays whenever an account, subaccount or template setting can't be checked, and the report's "Before cutover" list names what the person checks first.git revertthe merge commit. It removes the SparkPost variant.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 how each 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. Sites with a cc or bcc recipient (aheader_torecipient, aCCheader, or the SDK'sccandbcc), attachments or inline images stay too, because no PostHog SDK takes them yet.captureEngagement: falseand keeps a template site; one of each, or an unknown setting (account tracking not checked), stays. A site that tracked opens also stays when some addresses revoked open-tracking consent (Tracking Consent Revokedentries), unless the person accepts it, and whenenable_minimized_event_passthroughis on at the account, the subaccount (which takes the account's when it sets none), the transmission or the recipient. When the person can't say and the API can't be read, it stays withopen event minimization not checked.orfallback applied first, and HTML-escaped inhtml; a value in a link URL moves only when it is URL-safe. A statement, triple braces, macros ordata-msys-*links keep the site. Withperform_substitutions: false,textandhtmlgo as they are, butfrom,subjectandreply_toare still substituted and read the same way.ordefaults,if/elseif/elsewith comparisons and oneandoror,eachwithloop_var, and the recipient's address. Triple braces,loop_index, nestedloop_vars,#lengths, arithmetic, macros,data-msys-*links, a bare name insideeach, a template's own tracking ortransactionaloff, and tests on values that are not always booleans or lists keep it on SparkPost.400or422readsvalidation_error, a branch on420readsquota_limited, and a429branch reads status429with a code other thanquota_limited. Python, Ruby and .NET raisenetwork_erroragain, which SparkPost's error class never caught.typemaps to a$workflows_email_*event. A handler that matches events bytransmission_id,message_id,rcpt_meta,rcpt_tagsorcampaign_idkeeps its sites on SparkPost.transactionalentry without alist_idkeeps its SparkPost write and also writes PostHog's list, and a removal also removes from PostHog's list where feat(skills): move SendGrid sends to PostHog in every SDK language #459's Step 5 allows it.non_transactionalandopen_trackingwrites stay SparkPost writes only. A send that runs 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; the EU host for every request; a read-only key; the account and subaccount tracking, minimization andtransactional_defaultsettings; a template's published version and options. The suppression import reads each account's list on its own and takes bounces, spam complaints, and transactional manual and compliance entries. It leaves out unsubscribes, non-transactional andlist_identries, and revoked tracking consent, and counts them for the report.The mapping names PostHog's fields, client and errors through the rewrite file for the site's language. Those files come from #472, which the stack now sits on under #459: one
references/rewrite-<language>.mdper PostHog SDK (Node.js, Python, Go, Ruby, PHP, .NET, Rust and Elixir), and Step 7's single list of what every SDK's send takes. Steps 7 and 8 run each rewrite file's check on the whole project, in place of a typecheck per language. #459 deletesposthog-sdks.md, and the plan'slangcolumn is nowlanguage.Evidence
Lane gate on
ee9e706:bunx pnpm@10 testpasses 262 tests in 29 files.bunx pnpm@10 run buildbuildsmigrate-email-sparkpost.zip, andskill-menu.jsoncliEntrieslistssparkpostundermigrate, next to the other providers andstatsig.CI: green, 7 checks.
Proof gate:
fleet proof gateatee9e706:gate: pass (kinds: live-run, prod-shape; review: carried from 8289926; CI: green; judge: fail accepted by standing rule). The judge's gap is recorded under the standing rule: the dry run is the proof form the brief names, and an applied rewrite with typecheck and send can't run until the PostHog SDK releases with the code-form email send are out.Final read at
8289926: GPT-6.1 Sol read this round's change,e31f24band the forward merges below it: clean.Test seams (
migrate-email-skill.test.js): the SparkPost bundle resolves every reference, holds its three files and the eight rewrite files, noposthog-sdks.md, and no other provider's folder; itsmapping.mdnamesmigratedFrom: 'sparkpost'; it is amigratesubcommand.Reused at
ee9e706. This head adds only clean merges of #472, at 5398fd9 and 6924d48 (one test assertion), which leave this PR's own diff unchanged. 5398fd9 changes one Step 7 rule: behind a reverse proxy, email gets its own client on PostHog's host. Every fixture's PostHog host is PostHog's own, so the rule can't change a site, and the dry run and final read at8289926stand. The lane gate above ran at this head.Dry run at
8289926(live-run proof).8289926addse31f24b, which points the SparkPost mapping at the rewrite file for the site's language in place ofposthog-sdks.md(10 references). It turns the cc and bcc recipient row,content.attachmentsandcontent.inline_imagesinto stay rows ("Always. PostHog's send doesn't take it yet"), andcontent.headerskeeps a site for any header, theCCheader included. The "Cc and bcc" section now only says how to spot them: its rules for recipients with the same values and for address placeholders are gone. It drops cc, bcc and attachments from the template rule, the Node.js, PHP, HTTP, Python and Go call shapes, and the recipient conversion. The row for a language without a PostHog SDK that sends email now names Java or Kotlin and points at Step 7's list. The forward merge below it brings feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files and Step 7's list of what every SDK's send takes. The same headless dry run, on the same fixture, now runs with answers that no longer say an SDK takescc,bccor attachments. The expected outcome was written before the run:receipt.tsstays on cc and attachments, and every other site lands as in the last run. Result: 11 found, 4 moved, 7 left, as expected.receipt.tsstays with the reason "ccrecipient, andattachments". The plan uses thelanguagecolumn, the run never namesposthog-sdks.md, and the fixture was unchanged.Dry run on
9d8e1fe. Claude Code on Sonnet 5.5 ran the built skill as a dry run, headless, with only read tools, on a fixture app with eleven SparkPost sends in TypeScript, Python and Go, plus suppression-list writes and a read, and a webhook route. The person's answers were in the prompt (a US account without subaccounts, tracking on by default, transactional by default, noTracking Consent Revokedentries, open events not minimized); the expected outcomes were written before each run. The fixture was unchanged after the run.welcome.ts: inline{{name}}fromsubstitution_data, catch readserr.errorshtmlby a newescapeHtmlhelper; logs and returns onrecipient_suppressed; throws an error withstatusCode,nameandcodereceipt.ts: SDKccand a PDF attachmentccand the base64 attachment. Logsdata.idwhere it loggedresults.id(stays from8289926: cc and attachments)verify-email.ts: 2.x callback(err, data)doneis still called oncepassword-changed.ts: rawfetch,click_tracking: false, opens left to the accountcaptureEngagement: falseturns off bothinvoice.ts: stored templateinvoice-v2, a route passesreq.body.itemsitemsis not checked to be a listdigest.ts: several recipients andcampaign_idcampaign_idtrial.ts:options.start_timeshipping.ts: recipientmetadatarcpt_metasend-test-email.ts:fromonsparkpostbox.compassword_reset.py:except SparkPostAPIExceptionexcept EmailErrorreturnsTrueonrecipient_suppressed, as a sent mail would, and raisesnetwork_erroragainalert.go:(id, res, err)with a429brancherrors.Asreturns the rate-limit error on status429with a code other thanquota_limited, so a used-up quota takes the wrapped error path; the id comes fromresult.IDThe three suppression writes are listed with the other calls: the report asks the person to make the
transactionalupsert also write PostHog's list asSynced from SparkPost, to make the removal also remove from PostHog's list only for anImported fromorSynced fromentry, and to leave thenon_transactionalupsert a SparkPost write. The run leaves out feat(skills): move SendGrid sends to PostHog in every SDK language #459's "no other imported list holds it" clause; with one account and one list, that changes nothing here. The webhook route'sbounceandspam_complainthandling maps to$workflows_email_bouncedand$workflows_email_blocked. Summary block:from: sparkpost,sites_found: 11,sites_rewritten: 5,sites_left: 6, as expected.Prod-shape. A survey of real codebases' SparkPost send shapes (saved at
e702f00, reused: the commits after it change only tracking, error and suppression rules, which the dry run at this head covers, the forward merge of feat(skills): move Resend sends to posthog-node in migrate-email #451's Step 6 key check, and feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files). It added the sandbox domainsparkpostbox.com, the Node.js 1.xtransmissionBodyand callback shape, and fixture sites for raw HTTP sends, the callback form and an unverifiable sender.Gaps:
10d5d3bande702f00, before that rule, moved it to its PostHog template withif/elseandeachconverted.transactionaloff; subaccounts, includingopen_tracking_disabled, minimization inheritance and several accounts sharing one list; minimized open events on; revoked open-tracking consent present;perform_substitutions: false; an EU account; the Node.js 1.x shape;orfallbacks and link URL values; a branch on400,422or420; a send after a suppression removal;transactional_defaultoff;return_path; anIdempotency-Key; the suppression import. A fixture for these is a follow-up.Decisions
{{ }}html, as SparkPost rendered them. Any statement keeps the site. Withperform_substitutions: false,from,subjectandreply_toare still read, since SparkPost still substitutes themoptions.transactionaltransactional_default; unless that istrue, the plan notes it sent as non-transactional.transactional: falsestayscaptureEngagement: false; mixed, unknown, or a template with tracking off staysTracking Consent Revokedentries; when it does, a site that tracked opens stays unless the person accepts that PostHog tracks opens for those addressesenable_minimized_event_passthroughis on at the account, the subaccount (taking the account's when unset), the transmission or the recipient, since PostHog's send doesn't take it; unknown staysheader_torecipients and theCCheader. From8289926the site stays, as the cc, bcc and attachments row says. Before, it moved when every recipient carried the same values, since PostHog sends one email to all of themtransactional, read per account. Unsubscribes, non-transactional andlist_identries are marketing preferences, and revoked tracking consent blocks no sendtransactionalwrite without alist_idis mirrored into PostHog's list, the entries the import would take;non_transactionalandopen_trackingwrites stay SparkPost writeseachloop_varand don't say what a bare name reads400and422readvalidation_error,420readsquota_limited,429excludesquota_limited. Python, Ruby and .NET raisenetwork_erroragainInvolvement: qa-swarm, 2 panel rounds (round 1: fresh-eyes, correctness, risk, design, anti-overengineering, sweep; round 2: correctness, fresh-eyes, risk, sweep) and 18 final reads, the last at
8289926, clean. Proof captured: 21 dry runs over the review, the last at8289926, and a prod-shape survey.🤖 Agent context
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5, high), as the migration-skills slice of Silthus/posthog#461. Reviews on GPT-6.1 Sol (high), the fresh-eyes lens on Opus 5.5; fixes on Opus 5.5 (high). Dry runs on Claude Sonnet 5.5 (claude-sonnet-5-5).🤖 Generated with Claude Code