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. |
…domains whose sites move Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s that mirrored additions made Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… unchecked request input is named for the person Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ite on the provider Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to the API error's path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g, like Ruby's test mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…finds it Step 6 ticked the key phase as set when the person said they would add the key themselves. The step now checks the env file with a count-only grep (or a successful set_env_values in the Wizard). A promise to add it later, or a refusal, records the key as not set, and the report lists it before cutover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dotenv loads a key with leading whitespace or spaces around =, so the count-only grep accepts them too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r' into feat/migrate-email-sendgrid # Conflicts: # context/skills/migrate-email/references/9-report.md # scripts/lib/tests/migrate-email-skill.test.js
The count-only grep counted a key that an env loader reads as empty: a value that is only a comment (= #phs_x), and a key overridden by a later empty line. The check now reads only the last line that sets the variable and needs its value to start with phs_, still printing only a count. The test runs the documented command against fixture env files in place of matching its wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r' into feat/migrate-email-sendgrid
…plate rule per provider Resend also drops the text part for an empty text next to react, and for a text the code builds that can be empty. The shared template rule now keeps any template whose provider sent its emails with no text part, as that provider's mapping says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pty Text as left out Step 3 reads only the Check part of the template file, so the rule that keeps a template whose provider sent no text part moves there. Resend's Go SDK leaves an empty Text out of the request, so Resend made that text part from the HTML, and such a site moves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ain text off A dashboard copy shows no plain text both for a template without one and for one whose plain text is an empty string. Ask which, or read the template from the API; when nobody can tell, the site stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion and keep the answer A dashboard copy has no key to read the template from the API, so the question offers to make one. The answer goes into the template's plan note, so a copy made again in Step 7 asks nothing new. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly in Step 3 The template's note records its plain text however the template was read, so Step 7 never asks again, and the key is offered only in Step 3, before Step 5 asks the person to delete it. A site stays whenever neither the answer nor the API read settles it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostHog's bulk add takes only bounce, complaint or manual, and writes its own reason text, such as "Imported (bounced)". The removal guard now keys on that prefix, and the import maps each Resend origin to the reason value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…them PostHog's list records no source an app could check before a removal, a bulk add skips an address removed earlier, and an app can't call the import tool. PostHog blocks a transactional send only to an address that hard-bounced, and records that itself. So each such call becomes a "Before cutover" item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Map each Loops suppression source to PostHog's reason value, import an API-checked address as a bounce so moved sends skip it as Loops did, tell Step 3's new question that Loops has no way to add an address, and drop the "every removal counts" text Step 5 no longer reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…moved sends Addresses from the API check are imported as bounces, so the plan notes it under the suppression phase and the report qualifies its note that a complained address gets the moved sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n't be told apart A section that only the second preview shows, such as not_contains, can hold more than the preview's value, so a value shown there keeps the template on Loops, as a contains section already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 1f5bd4d: 1 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.
…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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed ba82a36: 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.
…Hog's doesn't Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A person who sends transactional email with Loops and asks to move those sends to PostHog gets no help:
migrate-emailcovers Resend, and each other provider comes in its own PR on top of #472.With this PR,
migrate-email-loopsmoves Loops transactional sends to PostHog's email send, in each language where both Loops and PostHog have an SDK or the app calls Loops' HTTP API, andwizard migrate loopsruns it.Important
Built on #472, the core
migrate-emailskill, which has merged: this PR targetsmain, withmainmerged in. Each provider PR adds only its own provider and lands in any order. The lists they all touch,config.yaml,AGENTS.mdand the skill test, conflict in one line each as they land.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: 'loops'show whether people use this skill to move off Loops. 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 publishes the skill, one-way once it is published · no switch-off
wizard migrate loops. No flag holds it back.wizard migrate loopsa public command that any agent can run, and a migration's changes persist in each app's code and PostHog account, such as the moved sends, the sender and its DNS records, the imported suppressions, and the PostHog key. No switch-off: a revert only stops new runs, since the next release ships without the Loops variant.Summary
references/loops/holds the three files each provider has:sdk-reference.md: Loops' official libraries in Node.js (and the Nuxt module), Go, PHP and Ruby, the community packages, the HTTP API, SMTP throughsmtp.loops.so, and Auth.js's Loops provider: how each sends, how it reports errors (API, rate limit and network errors apart), base URL options, the transactional emails stored at Loops with their data variables, arrays and conditional sections, the suppression removal calls, webhooks, and the calls that are not sends.mapping.md:templateKey, the renderedsubjectandhtml, andfromandreplyTofrom the email's settings. A template made in PostHog (templateIdwithtemplateData) is used only when the person asks for one.emailbecomesto, andIdempotency-KeybecomesidempotencyKeywhen the code bounds its characters. What keeps a site on Loops:addToAudience: trueunless the same code path creates or updates the contact first,attachments, other request headers, a From that holds a data variable, a CC or BCC, contact or event properties, and number or boolean conditions.loops.WithHTTPClientand PHP'ssetHttpClient, and Step 7 keeps them. An axiostimeoutand Faraday'stimeouton a non-net_httpadapter are limits Step 7 says the SDK can't keep.smtp.loops.sois read as the API send, since its text is the JSON body. Framework mailers, Auth.js, and a send after the code removes the recipient's Loops suppression stay. Each language keeps the site failing the way it did and never widens a catch:400readsvalidation_error,409readsidempotency_key_reused, a rate-limit branch takes429withoutquota_limited, and a retry loop on429stays.$workflows_email_*event, and the report names the handlers that read them.account.md: Loops' DNS records next to PostHog's, a separate migration key per Loops team (Loops keys have no scopes) that the person revokes at the end of Step 5, how to read an email's content from the API or a preview send, and the suppression import: Loops has no export, so it imports from the app's own webhook record or checks each audience contact at no more than 5 requests a second, and marks an audience-only import partial. Its report note says the core's weekly re-import, when it comes from the audience check, never covers a recipient who is not a contact, and names the app's record as what covers them.Evidence
vitest run scriptsandnode scripts/build.jspass locally.migrate-email-skill.test.js):loopsjoinsPROVIDERS, so the shared bundle tests generate the Loops skill and check that every reference resolves and that it holds its three files and the rewrite files.transactionalIdfrom an env variable, a helper whose callers pass the ID, and HTTP sends; the email content the code form now reads lives only in each person's Loops account. Michael's planned trial covers both.main,build.ymlruns on it and passes on this head, next to the lane gate's local vitest andbuild.js.Decisions
addToAudience: truesmtp.loops.soemptyis== '', andnot_equalandnot_containscheck the value is there first400, but only when the variable appears in content that shows for the site's values: Loops drops a hidden section with its variables. A variable only in hidden sections is named in the plan row, since Loops' docs don't say whether its400check skips it. The same holds for a PostHog template's precheckdataVariablesand LMX don't mark one optional; when they can't tell, the site stayscontext.Background(), since Loops' call took none and never stopped when a request endedWithHTTPClienttimeout limits each try and Loops retried twice, so it keeps PostHog's retries; the default 5-second Go timeout is not the site's own. A retry setting alone is not a deadlinenulldata variablenullwill not work, but not whether Loops then refused the send or left the value outreason: a hard bounce in the app's record tobounce, a spam report tocomplaint, and an address the API check finds suppressed tobounce, since the API doesn't say why and PostHog blocks moved sends only for a bounce. The report says those addresses are blocked, complaints included. Step 3's new question is told that Loops has no way to add an address to its list$workflows_email_bounced, told apart by$bounce_type(hardorsoft); the report says to filter on it before a hard-bounce handlerlimitandremainingInvolvement: qa-swarm, 2 panel rounds (round 1: fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and the team lenses of the author and a code owner; round 2: the lenses with fixes) and 20 final reads, reads 7 to 20 past the cap of six, each with its reason recorded. Read 12, on the fifth merge of #472, found that the core's weekly re-import can't keep its one-week promise for Loops' audience-only import; that is fixed in
2cb7e2b. ReviewHog ran 6 rounds before #472 landed; each finding was fixed or answered. After #472 landed,mainis merged in and the suppression mapping follows its shared steps; read 15 found that the report would say complained addresses get the moved sends while the API-checked ones are imported as bounces, fixed inc829289, and read 16 was clean on it. The proof judge then found that the preview-send guard missed anot_containssection, whose second preview shows a value that can't be told from fixed text; fixed in1f5bd4d, and read 17 was clean on it. ReviewHog's round after #472 found that a migrated retry loop dropped the wait PostHog's error names; it now reads the retry delay under each SDK's name and unit, fixed in598d376, and read 18 was clean on it. The proof judge then found that the Node.js bullet's thrown error dropped that delay; it now copiesretryAfterMs, fixed inba82a36, and read 19 was clean on it. ReviewHog's next round found that Loops' Go client also retried a5xx, which PostHog's SDK doesn't; the rule now says so and notes it in the plan row, fixed inff8ea2b, and read 20 was clean on this head. Shadow diff on this head againstmain: only the new Loops bundle, the manifest and the skill menu change. Live-run and prod-shape are recorded gaps.🤖 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