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. |
PostHog answers a used-up quota with status 429 and code quota_limited, which no retry fixes, so a retry or wait on 429 checks the code too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d's Node rethrow err.code keeps SendGrid's status for the old catch; err.posthogCode lets a 429 branch skip quota_limited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and keep suppression writes in step with PostHog's list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…from PostHog's suppression list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… no imported list still holds the address, on every path of Step 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…check 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>
This was referenced Nov 4, 2026
…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
Code sends now need transactional_email:write. A key with hog_flow:write keeps working through the legacy scope fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…feat/migrate-email-resend-cutover
…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>
… and gates its branches on the mark Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ther calls' errors 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>
… the marked forms 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>
…o feat/migrate-email-postmark
…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>
…text part - The calls that change the suppression list count for Step 3 and Step 5 only on the outbound stream of a server whose sites move, and Step 3's question names only the calls that add an address. - A moved send reaches an address that complained or that someone added, as Step 5 says; Step 3 keeps only sites that rely on an added one. - A send or template with no text body moves only when an email Postmark sent for it had a text part, else it stays with turns the plain-text part off, or plain-text part not checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut one The person checks one email Postmark sent with no TextBody or an empty one, which settles every such site; an email whose send set a text body settles nothing. A bounce activation counts unless the code activates only bounces it read from another stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sample from a send that sets the content settles only such sends, and one from a template without a text body settles only template sites, since Postmark builds a template's email itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A TextBody left out, one sent empty, and a template without a text body are separate cases, each settled by an email sent in that case; the installed client says whether an empty TextBody is sent or left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sent email doesn't show its request's case, so the agent names a site that sends only in that case and the person opens its email; a case with no such site stays. The template row points at the template case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's caller An app that sends through one helper has no site that sends only in one case, so the agent may name a caller of the helper whose sends always are, and the person opens that caller's email. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus
marked this pull request as draft
November 14, 2026 17:46
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 Postmark and asks to move those sends to PostHog gets no help:
migrate-emailcovers Resend and, with #459, SendGrid.With this PR,
migrate-email-postmarkmoves Postmark sends to PostHog's email send in each language where both Postmark and PostHog have an SDK, andwizard migrate postmarkruns it.Important
Stacked on #472, the core
migrate-emailskill. #472 has merged intomain(8cd7381), and this PR now 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, conflict in one line each as they land.It adds one provider folder, one config variant, one
AGENTS.mdrow and the provider in the bundle test. The shared steps it uses come from #472.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: the rule for #451 and every PR stacked on it.
Impact
Sends that arrive with
migratedFrom: 'postmark'show whether people use this skill to move off Postmark. 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
wizard migrate postmarkgets it. The skill then rewrites their Postmark send code, and a wrong instruction silently changes how their sends behave once they deploy: a refusal swallowed, a reply-to, subject or deadline changed, a template rendered differently, or a suppressed address mailed. Under feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rule, PostHog blocks a moved send only to an address that hard-bounced: an imported spam complaint or manual suppression no longer stops a moved send, and Step 3 keeps each site the person relies on Postmark's list for an address someone added.migrate-email-resendincluded. The shadow diff againstmainat8cd7381(feat(migrate-email): move Resend sends in every PostHog SDK's language #472 merged) shows only the newmigrate-email-postmarkbundle, its manifest entry and its skill-menu entries.Rollout
migratedFrom: 'postmark', and their error codes. No alert pages on a wrong rewrite.Summary
references/postmark/holds the three files each variant has:sdk-reference.md: Postmark's official libraries in Node.js, Python, Ruby, PHP, .NET and Java, the community libraries in Python (postmarker) and Go, and the HTTP API. It covers how each reports errors, stored templates and layouts, the test token, status reads, webhooks, and the APIs that are not sends.mapping.md:Headers,Metadata,Tag, aMessageStreamother thanoutbound, batch and bulk sends, SMTP and framework mailers, and branches on otherErrorCodes. Sites withCc,BccorAttachmentsstay too, because no PostHog SDK takes them yet. A send that is not a template send and leavesSubjectout, or can send it empty, stays, because PostHog's send requires one. A batch call with a written list of exactly one message is one send.ReplyTotook its sender signature's Reply-To address, as Postmark's Email API says. The person says perFromaddress whether the signature has one; the moved send sets it asreplyTo, and an unanswered address keeps the site (sender signature reply-to not checked).postmarkerand .NET sendTrackLinks: None; PHP's model sends,sendEmailModelandsendEmailWithTemplateModel, sendTrackOpens: false; Go leaves afalseout), or the server's setting. Tracking never maps tocaptureEngagement: PostHog adds its open pixel to every email, and counts clicks only for a template made in PostHog that is not marked sensitive. Opens Postmark didn't track follow feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 3, "Opens the provider didn't track": the person is asked once whether PostHog may add its open pixel; yes moves those sites, no or no answer keeps them (open tracking off), and an unknown server open setting keeps the site (open tracking not checked). Opens tracked moves, and the plan row notes when PostHog counts no clicks where Postmark tracked links. A site moving to a template made in PostHog whose links Postmark left alone, or tracked only in part (HtmlOnlyorTextOnlywith a link in the other body, or adata-pm-no-tracklink), or whose server setting is unknown, follows feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 3: the person marks the template sensitive, or the site stays (click tracking off,click tracking not checked).POSTMARK_API_TESTor through a sandbox server delivered nothing, and PostHog has no send that delivers nothing: the site moves only when the app already replaces its sending there or the person accepts real delivery there, and "Before cutover" names each environment where the person accepted real delivery.postmarkerclient built withinline_cssinlinedHtmlBody's<style>CSS before every send, aspostmarker's docs say under "CSS inlining". The moved send runs the samecss-inlineinliner: forinline_css=Trueone withload_remote_stylesheets=False, sincepostmarker's loads no<link>stylesheet, and an inliner the code passed as it is; and whencss-inlinecame only throughpostmarker's extra, the rewrite adds it as its own dependency so removingpostmarkerafter the cutover keeps it.template-to-code.mdsays; a template made in PostHog only when the person asks for one. The Mustachio table covers escaping, sections and Mustachio's truth test,each,../(each step of a dotted section name is a level), the fallback written as two parts (falserendersFalsethere), booleans asTrue/False, andInlineCss. Partials,pm:unsubscribe, a section on a value that can be a list, a CSS rule whose match depends on a value, and a subject that can render empty keep it on Postmark. For a template made in PostHog, a value that can be a boolean, a list or an object stays too, since Liquid printstrueor the contents where Mustachio printedTrueor nothing. A PHPTemplatedPostmarkMessagebuilt with'InlineCss' => falsestill inlined.timeoutfrom version 5 (before it, the option is an axiostimeout, which Step 7 keeps the site for, as it does a raw axiostimeoutand a Ruby whole-request limit),postmark-python's andpostmarker'stimeout, Ruby's open and read timeouts when every one the code sets is 60, since the gem leavesNet::HTTP's write timeout at 60 and feat(migrate-email): move Resend sends in every PostHog SDK's language #472'srewrite-ruby.mdkeeps a deadline only when open, read and write match (any other mix keeps the site), PHP's constructor timeout, a .NETClientFactory, and a Go context orHTTPClient. The SDK defaults don't. feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 7 says what to do with the deadline; a Postmark client that retried (postmark-python'sretries, a setmax_retries) keeps its count instead of none. Which failures a retry covers is PostHog's: it retries a server error only on 503, so a 500 thatpostmark-pythonretried, or any other 5xx the Ruby gem retried, now fails at the first try; the plan notes each such site and the report puts it under "Before cutover". A send a caller can cancel stays unless the PostHog send takes the same cancellation.trythat also covers a Postmark call that stays keeps Postmark's handler for it next to PostHog's, as the Resend mapping does, and a kept branch on a field PostHog's error also has, such as a status, first checks that the error is not PostHog's; in Node.js the rewrite marks PostHog's error at every moved send: a branch on a Postmark class checks the mark, and a branch on a field keeps its old check for unmarked errors.ApiInputError, a422,ValidationExceptionandInvalidEmailRequestErrorreadvalidation_error; on a site that moves to a template made in PostHog, theApiInputErrorand422branches also takenot_found(status404), which replaces Postmark's missing-template1101, and Python'sValidationExceptiondoes only when the installed client raises it for1101; the last two excluderecipient_suppressed, which they never covered, and Node.js's and Ruby'sInvalidEmailRequestError(code300only) also excludessender_not_verified. Ruby'srescue Postmark::Errorcaught timeouts and malformed answers but let connection failures through, so, like a rescue ofPostmark::TimeoutErroror Python'sexcept TimeoutException, it tells network failures apart and keeps its site, as feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 7 says. A rate-limit branch is429with any code butquota_limited.InternalServerErroris500only, and Python'sServerException500or503. A catch of Postmark's API errors only raisesnetwork_erroragain. Postmark's docs don't say whether such a catch took an answer whose body the client couldn't parse, such as a proxy's HTML 502, which PostHog's SDKs report asapi_error. So the agent reads the Postmark client installed in the app (for .NET, whose package is compiled, the client's source at the installed version), names the file in the plan row, and raisesapi_erroragain too when that answer went past the catch; a class the client raises only for such an answer becomes a catch onapi_error, and a class a language bullet names, such as Ruby'sPostmark::HttpClientError, keeps that bullet's rule. When the read doesn't settle it, the site stays (Postmark's handling of an answer it couldn't parse not checked). A Node.js site that passes a callback gets it called from PostHog's answer, and an awaited promise still resolves with the answer and rejects on an error. AStatusorErrorCodecheck after a single .NET or PHP send is removed, since both clients threw on any answer but200. A batch of one logs a refused message and raises every other failure.RecordTypemaps to a$workflows_email_*event. A click maps only for a template made in PostHog that is not marked sensitive, so a site whose clicks feed a handler moves only there.SubscriptionChangehas no PostHog event, so a site whose emails aSubscriptionChangehandler acts on stays. A handler that matches events byMessageID,MetadataorTagkeeps its sites on Postmark.outboundstream of a server whose sites move count, and Step 3's question names only the calls that add an address.TextBodyleft out, sent empty, or a template without a text body, the agent names a code path that sends only in that case, a site or one caller of a shared helper, and the person checks its email: with a text part, those sites move; without one, they stay (turns the plain-text part off); with none found, they stay (plain-text part not checked). Code that reads a header of Postmark's answer stays, since PostHog's answer carries none.account.md: Postmark's DKIM and Return-Path records next to PostHog's, a migration token per server for templates, tracking settings, delivery type and suppressions, deleted when feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 5 asks, where the person reads a sender signature's Reply-To (never with the Account API token), and the import of theoutboundstream's suppressions, each mapped to PostHog'sreason:HardBouncetobounce,SpamComplainttocomplaint,ManualSuppressiontomanual. For feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 3 question, it names how an address gets onto theoutboundlist. Broadcast streams and other transactional streams are left out, with the reason why. A re-import under feat(migrate-email): move Resend sends in every PostHog SDK's language #472's "Re-import only" has no plan, so its "For the report" section carries each server the moved sites send from, imported or not, with whether its list is fully in; the re-import asks the person to confirm those servers, never adds one whose sites all stayed, and asks for each server's token when its list needs it.The mapping names PostHog's fields, client and errors through #472's rewrite file for the site's language.
Evidence
8ead8e1(83e2ace): no conflicts; the core's trimmed skill test keeps this PR's onePROVIDERSline.b0dc650): the deadline options under "HTTP", the suppression calls in their own section, cancellable sends, sensitive and transactional PostHog templates, clicks, and the key and template-copy lifetimes now defer to the core's steps.7ccc0ee(b9628d4): no conflicts. It brought Step 7's rules on telling network failures apart and on connect and read limits;8ee4ebacuts the Postmark mapping to the classes and settings those rules cover.f658354(92d8d27): no conflicts. It brought Step 3's rule for clicks the provider didn't track, Step 7's library-default and timeout-kind rules, the UUID message ID, and Ruby's timeout kinds;0e008f4points the Postmark mapping at them, addsaccount.md's "Click tracking" settings, and keeps postmark.js's pre-5 axiostimeoutand Faraday's non-net_httptimeoutas limits the SDK can't keep.2c5b80dand7a3be6dadd partial link tracking after final reads 8 and 9.17c6ca1(735d8f6): no conflicts. It brought Step 3's "Opens the provider didn't track", themust be marked sensitiveplan note, Step 8's real-SDK tests, and the core's events-without-a-PostHog-event rule;13b9a6apoints Postmark's untracked and unknown opens at Step 3 with its reason strings, says inaccount.mdwhich server setting means no open pixel, and keeps sites with aSubscriptionChangehandler. The Postmark folder had no own sensitive-note or mocked-test text to drop.090ac92(d7eb8e8): no conflicts. It brought Step 5's "Re-import only" and the report's weekly re-import while any site stays on the provider, Step 8's tests without a mock of PostHog's send,project-getfor the project token, and the core test that checks this folder's "Step N says under" pointers and the bundle's isolation. The Postmark folder had no re-import, token or mock text of its own; final reads 12 to 15 then found that a re-import lost Postmark's server choice, fixed ina95b8f8,3338c3e,85fdbc3,347140aand1050901.mainafter feat(migrate-email): move Resend sends in every PostHog SDK's language #472 landed:78931b9merges feat(migrate-email): move Resend sends in every PostHog SDK's language #472's last head418cc5awith no conflicts, and6eae350mergesmain(8cd7381), taking this branch's side of the skill test, the only add/add conflict, whose blob onmainequals418cc5a's; the merge adds nothing beyond418cc5a.1c2357dadapts the bundle to feat(migrate-email): move Resend sends in every PostHog SDK's language #472's last changes: PostHog's suppressionreason, Step 3's question on sites that rely on Postmark's list, the stream text Step 5 no longer reads, the text part, and answer headers.0ae5ce0fixes final read 47,8b9e398final read 48 (the sample email must be one sent without a text body; bounce activations),6730c15final read 49 (one sample per send kind),36b4054final read 50 (left-out and emptyTextBodyare separate cases),c7fa4dcfinal read 51 (a named site per case; the template row's pointer), and33ed8a4final read 52 (a caller of a shared helper can be the named path).33ed8a4:node_modules/.bin/vitest run scriptspasses 344 tests in 30 files, Postmark's pointer check included, andnode scripts/build.jsexits 0 with a clean tree.33ed8a4againstmainat8cd7381(feat(migrate-email): move Resend sends in every PostHog SDK's language #472 merged): both sides built, and onlymigrate-email-postmark(new), its manifest entry and its skill-menu entries differ; every other bundle is byte-identical.migrate-email-skill.test.js, as feat(migrate-email): move Resend sends in every PostHog SDK's language #472 trimmed it): the Postmark bundle builds with its three files, the eight rewrite files and no other provider's folder, and the Wizard menu publishes it as amigratesubcommand. Wording-only tests, the Postmark one included, were dropped: they pinned sentences, not behavior.0fc1cc8: sender-signature reply-to, the Mustachio fallback, booleans in Liquid, Node.js callbacks. Fixed in0418350.62c97ba: PHP's template-model tracking default, lists and objects in Liquid. Fixed inec2e02b, with the siblingInlineCssdefault of PHP's template model.ec2e02b: a narrowInvalidEmailRequestErrorcatch also caughtsender_not_verified. Fixed in834645b. Keeping a raw HTTP send's timeout and retry policy waited on feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files; feat(migrate-email): move Resend sends in every PostHog SDK's language #472 now names them under "A deadline", andb0dc650maps Postmark's options to them.834645b: Ruby's base rescue and network failures. Fixed in438fa0d, and ina028874after final read 4 found that the first fix stopped rescuing timeouts.f9085cc: Ruby's base rescue also caught the malformed answers the gem raises asPostmark::HttpClientError. Fixed in0db4985.0db4985: bounce activation also takes an address off the suppression list. Fixed in77b10d3.1050901: the postmark gem leavesNet::HTTP's write timeout at 60 seconds, so equal open and read timeouts other than 60 are a mix the SDK can't keep. Fixed in994d846.43318e5:postmark-pythonretried a 500 and the Ruby gem every 5xx, while PostHog's SDK retries only a 503. Checked inpostmark-python0.5.0, the postmark gem 1.25.1 and PostHog's Python and Ruby SDKs;4d1b5a3notes each such site in the plan and the report, since keeping them would hold back nearly everypostmark-pythonsite (3 retries by default).9fa7ee2: no findings.ec25608: Postmark's .NET client throwsPostmarkResponseException, outsidecatch (PostmarkValidationException), for an answer it can't read, which PostHog's .NET SDK reports asapi_error. Fixed in84badee, and ine7e3dfeafter final read 24 found that the general catch rule still listed .NET as re-raising onlynetwork_errorand that acatch (PostmarkResponseException)had no rule. Superseded in1804f93, below.e7e3dfe: a proxy's HTML 502 may go pastpostmarker'sexcept ClientErrorwhile the rewrite re-raises onlynetwork_error, so a logging-only catch would swallow PostHog'sapi_error. PostHog's side holds: posthog-python, posthog-ruby, posthog-php and posthog-dotnet name an error answer without a codeapi_error. Postmark's side isn't in Postmark's orpostmarker's docs, and the SDK orgs (ActiveCampaign, Stranger6667, mrz1836, keighl) aren't on Fleet's Git allowlist, so per Michael's rule of 2026-11-13 the per-SDK claim is dropped, the .NET one from84badeeincluded. Fixed in1804f93for every SDK row: the agent reads the installed client, as Errors above says. Final read 26 found that .NET's package is compiled and that the rule collided with Ruby'sPostmark::HttpClientError; fixed inf217410.f217410:postmarker'sinline_cssclient option inlinedHtmlBody's CSS, which copyingHtmlBodytohtmldrops. Confirmed inpostmarker's docs. Fixed inb8ce69a, and in39dfebaafter final read 28 found thatcss-inlinecould leave withpostmarker's extra and that a batch of one wasn't named.39dfeba: a defaultcss_inline.CSSInliner()can load<link>stylesheets, whichpostmarker's inliner never does, so the app's server would fetch every<link>URL in the email. Confirmed inpostmarker's docs. Fixed in400e546withload_remote_stylesheets=False, and other inliner options read from the installedpostmarker.4497008: Ruby'sdeliver_message(mail)can set delivery state on theMail::Message, such asdelivered?, which a plain rewrite leaves unset. Not in Postmark's docs, so the agent reads the installed gem for what it sets, and code that reads that state reads PostHog's answer instead. Fixed in8a8c478, and in2a73521after final read 32 found that a read in an error path or in a caller that passedmailin had no rule: it maps to PostHog's error, and a read that can't be mapped keeps the site. Bounded in8f8e3f8after final read 33: only a read in the site's own method after the call is mapped; a retry guard before the call or a read in a caller keeps the site, and the rewrite never sets that state onmail. Widened in64922acafter final read 34 to every call that takes aMail::Message(deliver_message_with_templateand a batch of onedeliver_messagestoo) and to code that gets the samemaillater. In17b5021, after final read 35, a batch of one whose message state is read keeps the site, since the batch route answered a refused message without an error.17b5021: for a site moved to a template made in PostHog, Postmark'sApiInputErroror422branch also covered a missing template (1101,422in Postmark's API reference), which PostHog answers with404not_found. Fixed in29b3f03: that branch also takesnot_found, the code PostHog's transactional email docs give. In9e3ad05, after final read 37, each language bullet's mapping takes the check, and Python'sValidationExceptiondoes when the installed client raises it for1101.761b306: atrycovering a moved send and a Postmark call that stays lost Postmark's handler for that call. Fixed inb3482cbwith the Resend mapping's rule: keep Postmark's handler for the call that stays, and add PostHog's next to it. Refined in053022ato265a7cdafter final reads 40 to 45, so neither side's branch takes the other's errors, nor a third call's.265a7cd: no findings.0418350: fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and the author's ReviewHog perspective. Fixed inc76f683: the wording test, the test token and sandbox servers, an empty subject, exact error statuses andquota_limited,../in dotted sections, value-dependent CSS, an awaited callback send, Go's droppedTrackOpens: false, and a scope sentence insdk-reference.mdthat disagreed with the mapping.c76f683(fix diff; fresh-eyes, correctness, risk, design, anti-overengineering, systems, the author's perspective): the test-token advice still delivered real email with a test project's key, the subject rule caught template sends, and Node.js's rate-limit branch. Fixed in62c97ba. Aprod-shapefinding was first rejected; it is now owed and recorded as a gap (see Gaps).62c97ba,ec2e02band834645b; one on438fa0d(Ruby timeouts, fixed ina028874); none ona028874. Final read 6, on the merge and convergence diff sincea028874, ran correctness on GPT-6.1 Sol and fresh eyes on Opus 5.5: Ruby's open and read timeouts don't add up to one deadline, and itsmax_retriescounts the first try. Both fixed inf9085cc. Final read 7 (GPT-6.1 Sol, under the standing review rule), on everything sinceb0dc650: the Ruby fixes, the ReviewHog fixes, the second merge and the reconcile. No findings. Final reads 8 to 10 (GPT-6.1 Sol, standing rule) on the third merge's reconcile since8ee4eba: 8 found that partial link tracking (HtmlOnly,TextOnly,data-pm-no-track) counted as tracked, fixed in2c5b80d; 9 found thatHtmlOnlywith no link in the text body tracked every link, fixed in7a3be6d; 10 had no findings. Final read 11 (GPT-6.1 Sol, standing rule) on the fourth merge's reconcile since7a3be6d: no findings. Final reads 12 to 16 (correctness on GPT-6.1 Sol, fresh eyes on Opus 5.5, standing rule) on the fifth merge since13b9a6a: 12 found that a re-import has no plan naming Postmark's servers or tokens, fixed ina95b8f8; 13 found that the re-import couldn't recover Step 3's server choice and that its pointers escaped the pointer test, fixed in3338c3e; 14 found that the report named only the servers the import took, fixed in85fdbc3; 15 found that the per-server mark was due before the import ran, fixed in347140a, and this stale description; 16 found that the mark's trigger fired before the list was fetched, fixed in1050901by writing it at Step 5's "The full-access key". Final reads 17 to 25 (same lenses, standing rule): 17 had no findings; 18, on ReviewHog's fix since1050901, found that a Ruby site setting only one timeout, at 60, kept a deadline the SDK can keep, fixed in43318e5; 19 and 20 found this stale description; 20 started while a shadow build had the checkout on the base, so it was run again as 21; 21 found that the retry and test-environment notes had no route into Step 9's "Before cutover", fixed in9fa7ee2by writing them as plan notes that start withbefore cutover:; 22 found that the test-environment note recorded a choice instead of a task, fixed inec25608; 23 found this stale description; 24 found the two .NET catch gaps, fixed ine7e3dfe; 25 had no findings, and its note thatapi_erroralso covers an error answer without a code is answered from the sources: PostHog's API puts a code on every error it answers, so only an answer outside that envelope, such as an HTML 500 or a proxy's page, readsapi_error. Final reads 26 and 27 (same lenses, standing rule) on ReviewHog'se7e3dfefinding: 26, sincee7e3dfe, found that .NET's installed package is compiled, that the rule collided with Ruby'sPostmark::HttpClientError, and this stale description, fixed inf217410; 27, since1804f93, found only this stale description. Final read 28 (same lenses, standing rule) on ReviewHog'sf217410finding, sincef217410:css-inlinecould leave withpostmarker's extra after the cutover, the rule didn't name a batch of one, and this stale description; fixed in39dfeba. Final read 29 (same lenses, standing rule) sinceb8ce69a: no findings. Final read 30 (same lenses, standing rule) on ReviewHog's39dfebafinding, since39dfeba: the remote-stylesheet rule could rebuild an inliner the code passed, fixed in4497008. Final read 31 (same lenses, standing rule) since400e546: no findings. Final read 32 (same lenses, standing rule) on ReviewHog's4497008finding, since4497008: the error-path reads, fixed in2a73521. Final read 33 (same lenses, standing rule) since8a8c478: a caller's read and a retry guard before the call, fixed in8f8e3f8by bounding the rule. Final read 34 (same lenses, standing rule) since2a73521: the template and batch-of-one calls and amailreached later, fixed in64922ac. Final read 35 (same lenses, standing rule) since8f8e3f8: both lenses found that a batch of one maps a refusal to sent, fixed in17b5021. Final read 36 (same lenses, standing rule) since64922ac: no findings. Final read 37 (same lenses, standing rule) on ReviewHog's17b5021finding, since17b5021: the language bullets lacked the check and the code lacked a source, fixed in9e3ad05. Final read 38 (same lenses, standing rule) since29b3f03: an unnamed antecedent ("never did") read as contradicting theValidationExceptionsentence, fixed in761b306. Final read 39 (same lenses, standing rule) since9e3ad05: this description's Errors summary lacked thenot_foundclause, added. Final read 40 (same lenses, standing rule) on ReviewHog's761b306finding, since761b306: a kept status-only branch would also take PostHog's errors, such asquota_limited, fixed in053022a. Final read 41 (same lenses, standing rule) sinceb3482cb: requiring Postmark's class dropped another call's422from the kept branch, fixed ind125420by skipping PostHog's error instead. Final read 42 (same lenses, standing rule) since053022a: in Node.js another call's429could take PostHog's field-check branch, fixed in396d74awith the marker on PostHog's branches. Final read 43 (same lenses, standing rule) sinced125420: the mark applied only where a Postmark call stays, so another service's500in the sametrycould take the email's branch; fixed in88ec3c6by marking at every moved send. Final read 44 (same lenses, standing rule) since396d74a: the mark on every branch narrowed old field checks that also took other calls' errors, and an unparenthesized||escaped the mark; fixed in8dea6c3witherr.posthog ? (mapped) : (old)for field branches. Final read 45 (same lenses, standing rule) since88ec3c6: a nit, the Node.js list read as final forms could drop the mark; fixed in265a7cdby labelling it as the mapped checks. Final read 46 (same lenses, standing rule) since8dea6c3. Final read 47 (same lenses, standing rule), on the merge ofmainafter feat(migrate-email): move Resend sends in every PostHog SDK's language #472 and the adaptation since265a7cd: a moved send reaches a complainant, which the text overstated as kept by Step 3; the dropped stream scope of the suppression calls; an HTML-only send moving on an unknown text part; and this description. All fixed in0ae5ce0. Final read 48 (same lenses, standing rule) since1c2357d: the text-part sample could come from a send that set a text body, and a bounce activation's stream is known only at run time; fixed in8b9e398. Final read 49 (same lenses, standing rule) since0ae5ce0: one sample settled both plain and template sends; fixed in6730c15. Final read 50 (same lenses, standing rule) since8b9e398: a left-out and an emptyTextBodyshared one sample; fixed in36b4054. Final read 51 (same lenses, standing rule) since6730c15: a sent email can't show its request's case, and the template row pointed at the wrong case; fixed inc7fa4dc. Final read 52 (same lenses, standing rule) since36b4054: an app sending through one helper had no site to name; fixed in33ed8a4.8bc3253andab933earan the built skill headless on a twelve-site fixture (12 found, 6 moved, 6 left atab933ea). They predate templates moving into the code by default (54a6b78) and every rule added since, so they no longer cover the template path or this round's rules.13b9a6a; the acceptance carries to later heads):build.ymlruns on PRs intomain, which this PR now targets. The lane gate also ran vitest andbuild.jslocally on the head.Decisions
captureEngagement: opens tracked moves with a no-clicks note. Replaced moving neither tracked withcaptureEngagement: falseopen tracking off); unknown keeps the site (open tracking not checked). Replaces this PR's earlier "opens untracked stays"ReplyToFromaddress; unanswered stays. The Account API token is never asked foroutbound, the stream the moved sites send throughInvolvement: ReviewHog, 17 rounds (20 findings: 19 fixed, 1 closed by #472's deadline options); qa-swarm, 2 panel rounds (7 lenses each) and 46 final reads; the fleet proof judge passed on
13b9a6a,1050901,e7e3dfe,f217410,39dfeba,4497008,17b5021,761b306and265a7cd.🤖 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).🤖 Generated with Claude Code