Skip to content

feat(skills): move Postmark sends to PostHog - #460

Draft
Silthus wants to merge 340 commits into
mainfrom
feat/migrate-email-postmark
Draft

Silthus wants to merge 340 commits into
mainfrom
feat/migrate-email-postmark

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Parked until after 2026-11-25. Michael chose SendGrid as the first migrate-email provider on 2026-11-14; this PR stays a draft and resumes after that date, with the slim pass from research/2026-11-14-review-bloat-retro before its next review round.

Problem

A person who sends with Postmark and asks to move those sends to PostHog gets no help: migrate-email covers Resend and, with #459, SendGrid.

With this PR, migrate-email-postmark moves Postmark sends to PostHog's email send in each language where both Postmark and PostHog have an SDK, and wizard migrate postmark runs it.

Important

Stacked on #472, the core migrate-email skill. #472 has merged into main (8cd7381), and this PR now targets main. Each provider PR adds its own provider and lands in any order. The lists they all touch, config.yaml, AGENTS.md and the skill test, conflict in one line each as they land.
It adds one provider folder, one config variant, one AGENTS.md row 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 runs migrate as 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

  • Once merged, the skill ships with the next context-mill release, and every person who runs wizard migrate postmark gets 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.
  • Uncovered: no run applies a rewrite yet, since the live run waits for the released email SDKs (see Gaps). Each moved site keeps its error behavior, except that a server error other than 503 that Postmark's client retried now fails at the first try, which the report lists; a narrow catch whose Postmark client the agent can't read for an answer it couldn't parse stays on Postmark; it keeps its open tracking and its deadline, a site stays whenever a setting it depends on can't be checked, and the report's "Before cutover" list names what the person checks first.
  • Unchanged: every other bundle, migrate-email-resend included. The shadow diff against main at 8cd7381 (feat(migrate-email): move Resend sends in every PostHog SDK's language #472 merged) shows only the new migrate-email-postmark bundle, its manifest entry and its skill-menu entries.
  • Once agents run it, a migration's changes stay in each app's code and PostHog account: the rewritten sends, the sender, the imported suppressions, the key, and the provider templates the person deletes after cutover.
  • Switch-off: none. A revert only stops new runs.
  • Tier: critical. context-mill keeps no critical-path list; the grade is high and the door turns one-way once agents run the skill, so an agent decides only process on this PR.

Rollout

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:
    • Fields. Each field maps to PostHog's or names what keeps a site on Postmark: Headers, Metadata, Tag, a MessageStream other than outbound, batch and bulk sends, SMTP and framework mailers, and branches on other ErrorCodes. Sites with Cc, Bcc or Attachments stay too, because no PostHog SDK takes them yet. A send that is not a template send and leaves Subject out, 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.
    • Reply-to. A send without ReplyTo took its sender signature's Reply-To address, as Postmark's Email API says. The person says per From address whether the signature has one; the moved send sets it as replyTo, and an unanswered address keeps the site (sender signature reply-to not checked).
    • Tracking. Opens and links are worked out per site from the send's field, the SDK's default (postmarker and .NET send TrackLinks: None; PHP's model sends, sendEmailModel and sendEmailWithTemplateModel, send TrackOpens: false; Go leaves a false out), or the server's setting. Tracking never maps to captureEngagement: 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 (HtmlOnly or TextOnly with a link in the other body, or a data-pm-no-track link), 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).
    • Servers. Each server has its own tokens, templates, tracking and lists. When moved sites send from several servers, the person agrees that they share PostHog's one suppression list, or only one server's sites move. An environment that sent with the test token POSTMARK_API_TEST or 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.
    • CSS inlining. A postmarker client built with inline_css inlined HtmlBody's <style> CSS before every send, as postmarker's docs say under "CSS inlining". The moved send runs the same css-inline inliner: for inline_css=True one with load_remote_stylesheets=False, since postmarker's loads no <link> stylesheet, and an inliner the code passed as it is; and when css-inline came only through postmarker's extra, the rewrite adds it as its own dependency so removing postmarker after the cutover keeps it.
    • Templates. A template send moves by bringing the template, inside its layout, into the code, as feat(migrate-email): move Resend sends in every PostHog SDK's language #472's template-to-code.md says; 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 (false renders Falsethere), booleans as True/False, and InlineCss. 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 prints true or the contents where Mustachio printed True or nothing. A PHP TemplatedPostmarkMessage built with 'InlineCss' => false still inlined.
    • Deadlines. Under "HTTP", the timeouts a site sets count as its own deadline: on a raw request, postmark.js's timeout from version 5 (before it, the option is an axios timeout, which Step 7 keeps the site for, as it does a raw axios timeout and a Ruby whole-request limit), postmark-python's and postmarker's timeout, Ruby's open and read timeouts when every one the code sets is 60, since the gem leaves Net::HTTP's write timeout at 60 and feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite-ruby.md keeps a deadline only when open, read and write match (any other mix keeps the site), PHP's constructor timeout, a .NET ClientFactory, and a Go context or HTTPClient. 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's retries, a set max_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 that postmark-python retried, 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.
    • Errors. Never widen a catch. A try that 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, a 422, ValidationException and InvalidEmailRequestError read validation_error; on a site that moves to a template made in PostHog, the ApiInputError and 422 branches also take not_found (status 404), which replaces Postmark's missing-template 1101, and Python's ValidationException does only when the installed client raises it for 1101; the last two exclude recipient_suppressed, which they never covered, and Node.js's and Ruby's InvalidEmailRequestError (code 300 only) also excludes sender_not_verified. Ruby's rescue Postmark::Error caught timeouts and malformed answers but let connection failures through, so, like a rescue of Postmark::TimeoutError or Python's except 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 is 429 with any code but quota_limited. InternalServerError is 500 only, and Python's ServerException 500 or 503. A catch of Postmark's API errors only raises network_error again. 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 as api_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 raises api_error again too when that answer went past the catch; a class the client raises only for such an answer becomes a catch on api_error, and a class a language bullet names, such as Ruby's Postmark::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. A Status or ErrorCode check after a single .NET or PHP send is removed, since both clients threw on any answer but 200. A batch of one logs a refused message and raises every other failure.
    • Webhooks. Each RecordType maps 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. SubscriptionChange has no PostHog event, so a site whose emails a SubscriptionChange handler acts on stays. A handler that matches events by MessageID, Metadata or Tag keeps its sites on Postmark.
    • Templates made in PostHog. The person makes it transactional once a verified sender covers its address, and marks it sensitive when it carries a login code or a reset link, as feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 3 asks.
    • Suppression writes. "Calls that change the suppression list" names Postmark's add, delete, and reactivate calls for feat(migrate-email): move Resend sends in every PostHog SDK's language #472's Step 3 and Step 5; only calls on the outbound stream of a server whose sites move count, and Step 3's question names only the calls that add an address.
    • Text part. PostHog makes a text part from the HTML when a send has none, and Postmark's docs don't say whether Postmark did. For each case the sites need, TextBody left 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 the outbound stream's suppressions, each mapped to PostHog's reason: HardBounce to bounce, SpamComplaint to complaint, ManualSuppression to manual. 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 the outbound list. 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

  • Forward merge of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 at 8ead8e1 (83e2ace): no conflicts; the core's trimmed skill test keeps this PR's one PROVIDERS line.
  • Convergence with feat(migrate-email): move Resend sends in every PostHog SDK's language #472 (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.
  • Second forward merge of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 at 7ccc0ee (b9628d4): no conflicts. It brought Step 7's rules on telling network failures apart and on connect and read limits; 8ee4eba cuts the Postmark mapping to the classes and settings those rules cover.
  • Third forward merge of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 at 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; 0e008f4 points the Postmark mapping at them, adds account.md's "Click tracking" settings, and keeps postmark.js's pre-5 axios timeout and Faraday's non-net_http timeout as limits the SDK can't keep. 2c5b80d and 7a3be6d add partial link tracking after final reads 8 and 9.
  • Fourth forward merge of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 at 17c6ca1 (735d8f6): no conflicts. It brought Step 3's "Opens the provider didn't track", the must be marked sensitive plan note, Step 8's real-SDK tests, and the core's events-without-a-PostHog-event rule; 13b9a6a points Postmark's untracked and unknown opens at Step 3 with its reason strings, says in account.md which server setting means no open pixel, and keeps sites with a SubscriptionChange handler. The Postmark folder had no own sensitive-note or mocked-test text to drop.
  • Fifth forward merge of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 at 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-get for 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 in a95b8f8, 3338c3e, 85fdbc3, 347140a and 1050901.
  • Merge of main after feat(migrate-email): move Resend sends in every PostHog SDK's language #472 landed: 78931b9 merges feat(migrate-email): move Resend sends in every PostHog SDK's language #472's last head 418cc5a with no conflicts, and 6eae350 merges main (8cd7381), taking this branch's side of the skill test, the only add/add conflict, whose blob on main equals 418cc5a's; the merge adds nothing beyond 418cc5a. 1c2357d adapts the bundle to feat(migrate-email): move Resend sends in every PostHog SDK's language #472's last changes: PostHog's suppression reason, 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. 0ae5ce0 fixes final read 47, 8b9e398 final read 48 (the sample email must be one sent without a text body; bounce activations), 6730c15 final read 49 (one sample per send kind), 36b4054 final read 50 (left-out and empty TextBody are separate cases), c7fa4dc final read 51 (a named site per case; the template row's pointer), and 33ed8a4 final read 52 (a caller of a shared helper can be the named path).
  • Lane gate on 33ed8a4: node_modules/.bin/vitest run scripts passes 344 tests in 30 files, Postmark's pointer check included, and node scripts/build.js exits 0 with a clean tree.
  • Shadow diff of 33ed8a4 against main at 8cd7381 (feat(migrate-email): move Resend sends in every PostHog SDK's language #472 merged): both sides built, and only migrate-email-postmark (new), its manifest entry and its skill-menu entries differ; every other bundle is byte-identical.
  • Test seams (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 a migrate subcommand. Wording-only tests, the Postmark one included, were dropped: they pinned sentences, not behavior.
  • ReviewHog, each finding checked against Postmark's docs and SDK sources:
    • On 0fc1cc8: sender-signature reply-to, the Mustachio fallback, booleans in Liquid, Node.js callbacks. Fixed in 0418350.
    • On 62c97ba: PHP's template-model tracking default, lists and objects in Liquid. Fixed in ec2e02b, with the sibling InlineCss default of PHP's template model.
    • On ec2e02b: a narrow InvalidEmailRequestError catch also caught sender_not_verified. Fixed in 834645b. 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", and b0dc650 maps Postmark's options to them.
    • On 834645b: Ruby's base rescue and network failures. Fixed in 438fa0d, and in a028874 after final read 4 found that the first fix stopped rescuing timeouts.
    • On f9085cc: Ruby's base rescue also caught the malformed answers the gem raises as Postmark::HttpClientError. Fixed in 0db4985.
    • On 0db4985: bounce activation also takes an address off the suppression list. Fixed in 77b10d3.
    • On 1050901: the postmark gem leaves Net::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 in 994d846.
    • On 43318e5: postmark-python retried a 500 and the Ruby gem every 5xx, while PostHog's SDK retries only a 503. Checked in postmark-python 0.5.0, the postmark gem 1.25.1 and PostHog's Python and Ruby SDKs; 4d1b5a3 notes each such site in the plan and the report, since keeping them would hold back nearly every postmark-python site (3 retries by default).
      • These versions came from registry packages, and the retry counts from their source. #523 rechecks these claims against Postmark's docs and the client installed in the app.
    • On 9fa7ee2: no findings.
    • On ec25608: Postmark's .NET client throws PostmarkResponseException, outside catch (PostmarkValidationException), for an answer it can't read, which PostHog's .NET SDK reports as api_error. Fixed in 84badee, and in e7e3dfe after final read 24 found that the general catch rule still listed .NET as re-raising only network_error and that a catch (PostmarkResponseException) had no rule. Superseded in 1804f93, below.
    • On e7e3dfe: a proxy's HTML 502 may go past postmarker's except ClientError while the rewrite re-raises only network_error, so a logging-only catch would swallow PostHog's api_error. PostHog's side holds: posthog-python, posthog-ruby, posthog-php and posthog-dotnet name an error answer without a code api_error. Postmark's side isn't in Postmark's or postmarker'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 from 84badee included. Fixed in 1804f93 for 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's Postmark::HttpClientError; fixed in f217410.
    • On f217410: postmarker's inline_css client option inlined HtmlBody's CSS, which copying HtmlBody to html drops. Confirmed in postmarker's docs. Fixed in b8ce69a, and in 39dfeba after final read 28 found that css-inline could leave with postmarker's extra and that a batch of one wasn't named.
    • On 39dfeba: a default css_inline.CSSInliner() can load <link> stylesheets, which postmarker's inliner never does, so the app's server would fetch every <link> URL in the email. Confirmed in postmarker's docs. Fixed in 400e546 with load_remote_stylesheets=False, and other inliner options read from the installed postmarker.
    • On 4497008: Ruby's deliver_message(mail) can set delivery state on the Mail::Message, such as delivered?, 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 in 8a8c478, and in 2a73521 after final read 32 found that a read in an error path or in a caller that passed mail in had no rule: it maps to PostHog's error, and a read that can't be mapped keeps the site. Bounded in 8f8e3f8 after 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 on mail. Widened in 64922ac after final read 34 to every call that takes a Mail::Message (deliver_message_with_template and a batch of one deliver_messages too) and to code that gets the same mail later. In 17b5021, 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.
    • On 17b5021: for a site moved to a template made in PostHog, Postmark's ApiInputError or 422 branch also covered a missing template (1101, 422 in Postmark's API reference), which PostHog answers with 404 not_found. Fixed in 29b3f03: that branch also takes not_found, the code PostHog's transactional email docs give. In 9e3ad05, after final read 37, each language bullet's mapping takes the check, and Python's ValidationException does when the installed client raises it for 1101.
    • On 761b306: a try covering a moved send and a Postmark call that stays lost Postmark's handler for that call. Fixed in b3482cb with the Resend mapping's rule: keep Postmark's handler for the call that stays, and add PostHog's next to it. Refined in 053022a to 265a7cd after final reads 40 to 45, so neither side's branch takes the other's errors, nor a third call's.
    • On 265a7cd: no findings.
  • QA swarm, round 1 on 0418350: fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and the author's ReviewHog perspective. Fixed in c76f683: the wording test, the test token and sandbox servers, an empty subject, exact error statuses and quota_limited, ../ in dotted sections, value-dependent CSS, an awaited callback send, Go's dropped TrackOpens: false, and a scope sentence in sdk-reference.md that disagreed with the mapping.
  • QA swarm, round 2 on 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 in 62c97ba. A prod-shape finding was first rejected; it is now owed and recorded as a gap (see Gaps).
  • Final reads (GPT-6.1 Sol): none on 62c97ba, ec2e02b and 834645b; one on 438fa0d (Ruby timeouts, fixed in a028874); none on a028874. Final read 6, on the merge and convergence diff since a028874, 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 its max_retries counts the first try. Both fixed in f9085cc. Final read 7 (GPT-6.1 Sol, under the standing review rule), on everything since b0dc650: 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 since 8ee4eba: 8 found that partial link tracking (HtmlOnly, TextOnly, data-pm-no-track) counted as tracked, fixed in 2c5b80d; 9 found that HtmlOnly with no link in the text body tracked every link, fixed in 7a3be6d; 10 had no findings. Final read 11 (GPT-6.1 Sol, standing rule) on the fourth merge's reconcile since 7a3be6d: 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 since 13b9a6a: 12 found that a re-import has no plan naming Postmark's servers or tokens, fixed in a95b8f8; 13 found that the re-import couldn't recover Step 3's server choice and that its pointers escaped the pointer test, fixed in 3338c3e; 14 found that the report named only the servers the import took, fixed in 85fdbc3; 15 found that the per-server mark was due before the import ran, fixed in 347140a, and this stale description; 16 found that the mark's trigger fired before the list was fetched, fixed in 1050901 by 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 since 1050901, found that a Ruby site setting only one timeout, at 60, kept a deadline the SDK can keep, fixed in 43318e5; 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 in 9fa7ee2 by writing them as plan notes that start with before cutover:; 22 found that the test-environment note recorded a choice instead of a task, fixed in ec25608; 23 found this stale description; 24 found the two .NET catch gaps, fixed in e7e3dfe; 25 had no findings, and its note that api_error also 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, reads api_error. Final reads 26 and 27 (same lenses, standing rule) on ReviewHog's e7e3dfe finding: 26, since e7e3dfe, found that .NET's installed package is compiled, that the rule collided with Ruby's Postmark::HttpClientError, and this stale description, fixed in f217410; 27, since 1804f93, found only this stale description. Final read 28 (same lenses, standing rule) on ReviewHog's f217410 finding, since f217410: css-inline could leave with postmarker's extra after the cutover, the rule didn't name a batch of one, and this stale description; fixed in 39dfeba. Final read 29 (same lenses, standing rule) since b8ce69a: no findings. Final read 30 (same lenses, standing rule) on ReviewHog's 39dfeba finding, since 39dfeba: the remote-stylesheet rule could rebuild an inliner the code passed, fixed in 4497008. Final read 31 (same lenses, standing rule) since 400e546: no findings. Final read 32 (same lenses, standing rule) on ReviewHog's 4497008 finding, since 4497008: the error-path reads, fixed in 2a73521. Final read 33 (same lenses, standing rule) since 8a8c478: a caller's read and a retry guard before the call, fixed in 8f8e3f8 by bounding the rule. Final read 34 (same lenses, standing rule) since 2a73521: the template and batch-of-one calls and a mail reached later, fixed in 64922ac. Final read 35 (same lenses, standing rule) since 8f8e3f8: both lenses found that a batch of one maps a refusal to sent, fixed in 17b5021. Final read 36 (same lenses, standing rule) since 64922ac: no findings. Final read 37 (same lenses, standing rule) on ReviewHog's 17b5021 finding, since 17b5021: the language bullets lacked the check and the code lacked a source, fixed in 9e3ad05. Final read 38 (same lenses, standing rule) since 29b3f03: an unnamed antecedent ("never did") read as contradicting the ValidationException sentence, fixed in 761b306. Final read 39 (same lenses, standing rule) since 9e3ad05: this description's Errors summary lacked the not_found clause, added. Final read 40 (same lenses, standing rule) on ReviewHog's 761b306 finding, since 761b306: a kept status-only branch would also take PostHog's errors, such as quota_limited, fixed in 053022a. Final read 41 (same lenses, standing rule) since b3482cb: requiring Postmark's class dropped another call's 422 from the kept branch, fixed in d125420 by skipping PostHog's error instead. Final read 42 (same lenses, standing rule) since 053022a: in Node.js another call's 429 could take PostHog's field-check branch, fixed in 396d74a with the marker on PostHog's branches. Final read 43 (same lenses, standing rule) since d125420: the mark applied only where a Postmark call stays, so another service's 500 in the same try could take the email's branch; fixed in 88ec3c6 by marking at every moved send. Final read 44 (same lenses, standing rule) since 396d74a: the mark on every branch narrowed old field checks that also took other calls' errors, and an unparenthesized || escaped the mark; fixed in 8dea6c3 with err.posthog ? (mapped) : (old) for field branches. Final read 45 (same lenses, standing rule) since 88ec3c6: a nit, the Node.js list read as final forms could drop the mark; fixed in 265a7cd by labelling it as the mapped checks. Final read 46 (same lenses, standing rule) since 8dea6c3. Final read 47 (same lenses, standing rule), on the merge of main after feat(migrate-email): move Resend sends in every PostHog SDK's language #472 and the adaptation since 265a7cd: 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 in 0ae5ce0. Final read 48 (same lenses, standing rule) since 1c2357d: 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 in 8b9e398. Final read 49 (same lenses, standing rule) since 0ae5ce0: one sample settled both plain and template sends; fixed in 6730c15. Final read 50 (same lenses, standing rule) since 8b9e398: a left-out and an empty TextBody shared one sample; fixed in 36b4054. Final read 51 (same lenses, standing rule) since 6730c15: a sent email can't show its request's case, and the template row pointed at the wrong case; fixed in c7fa4dc. Final read 52 (same lenses, standing rule) since 36b4054: an app sending through one helper had no site to name; fixed in 33ed8a4.
  • Earlier dry runs at 8bc3253 and ab933ea ran the built skill headless on a twelve-site fixture (12 found, 6 moved, 6 left at ab933ea). 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.
  • Gaps (accepted by Michael on 13b9a6a; the acceptance carries to later heads):
    • Live run: an agent run of this provider's migration on a real app needs the released email SDKs and the production send API; a planned trial on generated codebases runs it after both land.
    • Prod shape: the inputs are customer-written send code and config and each person's Postmark account content; there is no store of real customer codebases to sample. The planned trial on generated codebases covers it after the SDKs land.
    • CI: build.yml runs on PRs into main, which this PR now targets. The lane gate also ran vitest and build.js locally on the head.

Decisions

Question Answer Who Where
When this merges Not before code sends are live and usable for customers, for #451 and every PR on it 👤 Human this PR
Which providers Mailgun, Loops, SendGrid, Postmark and SMTP, plus the providers the Wizard detects that a real codebase sends transactional email from 👤 Human Silthus/posthog#461
Postmark tracking Work out opens and links from the field, the SDK default, or the server. Tracking never maps to captureEngagement: opens tracked moves with a no-clicks note. Replaced moving neither tracked with captureEngagement: false 👤 Human this PR
Opens the provider didn't track Ask the person once whether PostHog may add its open pixel: yes moves those sites, no or no answer keeps them (open tracking off); unknown keeps the site (open tracking not checked). Replaces this PR's earlier "opens untracked stays" 👤 Human #472
Suppressions after cutover Recurring re-import: weekly while any site still sends through Postmark, and a final one when the last site leaves, as #472's Step 5 and report say 👤 Human #472
Proof gaps Accepted: live run, prod shape, and CI 👤 Human this PR
Who lands the provider PRs We land them ourselves, after every ReviewHog finding is addressed and the full QA swarm runs 👤 Human this PR
Stack shape One PR per provider, each on #472 🤖 Agent #472
Template sends Bring the template, with its layout, into the code; a template made in PostHog only on request 🤖 Agent #472
A send without ReplyTo Takes its sender signature's Reply-To, asked per From address; unanswered stays. The Account API token is never asked for 🤖 Agent this PR
Test token and sandbox servers A site moves only when the app already replaces its sending in that environment or the person accepts real delivery there; otherwise it stays 🤖 Agent this PR
Which streams' suppressions move Only outbound, the stream the moved sites send through 🤖 Agent this PR
Several servers, one PostHog list Ask whether the moved sites may share one list; if not, only one server's sites move 🤖 Agent this PR
A narrow catch on one Postmark error Becomes a catch of PostHog's error that checks the same kind and raises the rest, never wider 🤖 Agent this PR
Postmark SDK facts the docs don't state Cite only Postmark's docs and API reference; where they say nothing, the agent reads the client installed in the app and the site stays only when that doesn't settle it. Applied here to an answer the client couldn't parse; the other SDK-source claims are rechecked in the Postmark attachments follow-up 👤 Human Michael, 2026-11-13
cc, bcc and attachments Stay on Postmark in every language, since no PostHog SDK takes them yet (#472) 🤖 Agent this PR
Merge risk 🔴 high: two-way door until the next release ships the skill, one-way once agents run it, since a migration's changes persist in each app's code and PostHog account; no switch-off, a revert only stops new runs. A wrong instruction silently changes someone's sends, and the live run is uncovered 🤖 Agent this PR
Merge-risk tier Critical: context-mill keeps no critical-path list, but the grade is high and the door one-way once agents run the skill, so an agent decides only process here. Rejected: non-critical 🤖 Agent this PR
Server errors Postmark retried The site moves; the plan and the report name each site whose 500 is no longer retried. Rejected: keep those sites on Postmark, or a hand-written retry loop around each send 🤖 Agent this PR
Wording tests Dropped; no vitest seam runs an agent, and the live run is the behavior proof (a gap until the SDKs ship) 🤖 Agent this PR

Involvement: 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, 761b306 and 265a7cd.

🤖 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

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🧙 Wizard CI

Run 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:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci ai-observability
  • /wizard-ci basic-integration
  • /wizard-ci feature-flags
  • /wizard-ci mcp-analytics
  • /wizard-ci replay-vision
  • /wizard-ci revenue
  • /wizard-ci self-driving
  • /wizard-ci warehouse
  • /wizard-ci warehouse-seeded

Test an individual app:

  • /wizard-ci ai-observability/anthropic
  • /wizard-ci ai-observability/google-adk
  • /wizard-ci ai-observability/groq
Show more apps
  • /wizard-ci ai-observability/manual-capture
  • /wizard-ci ai-observability/openai
  • /wizard-ci ai-observability/openai-agents
  • /wizard-ci ai-observability/opentelemetry
  • /wizard-ci ai-observability/vercel-ai
  • /wizard-ci basic-integration/android
  • /wizard-ci basic-integration/angular
  • /wizard-ci basic-integration/astro
  • /wizard-ci basic-integration/django
  • /wizard-ci basic-integration/fastapi
  • /wizard-ci basic-integration/flask
  • /wizard-ci basic-integration/flutter
  • /wizard-ci basic-integration/javascript-node
  • /wizard-ci basic-integration/javascript-web
  • /wizard-ci basic-integration/laravel
  • /wizard-ci basic-integration/next-js
  • /wizard-ci basic-integration/nuxt
  • /wizard-ci basic-integration/python
  • /wizard-ci basic-integration/rails
  • /wizard-ci basic-integration/react-native
  • /wizard-ci basic-integration/react-router
  • /wizard-ci basic-integration/sveltekit
  • /wizard-ci basic-integration/swift
  • /wizard-ci basic-integration/tanstack-router
  • /wizard-ci basic-integration/tanstack-start
  • /wizard-ci basic-integration/vue
  • /wizard-ci feature-flags/django
  • /wizard-ci feature-flags/next-js
  • /wizard-ci mcp-analytics/custom-dispatcher
  • /wizard-ci mcp-analytics/typescript-sdk
  • /wizard-ci replay-vision/javascript-node
  • /wizard-ci replay-vision/next-js
  • /wizard-ci replay-vision/react-vite
  • /wizard-ci revenue/stripe
  • /wizard-ci self-driving/astro
  • /wizard-ci self-driving/fastapi
  • /wizard-ci self-driving/nuxt
  • /wizard-ci self-driving/react-router
  • /wizard-ci self-driving/sveltekit
  • /wizard-ci warehouse/monorepo-env
  • /wizard-ci warehouse/multi-source-next
  • /wizard-ci warehouse/stripe-node
  • /wizard-ci warehouse/zero-source
  • /wizard-ci warehouse-seeded/next-stripe
  • /wizard-ci warehouse-seeded/next-stripe-declined

Test against a wizard branch:

  • /wizard-ci all wizard:my-branch

Add wizard:<branch> to any command above to pin the wizard branch. It defaults to main.

Results will be posted here when complete.

Silthus and others added 17 commits November 4, 2026 12:32
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>
Silthus and others added 9 commits November 4, 2026 16:17
…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>
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>
Silthus and others added 6 commits November 13, 2026 15:16
…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 posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 265a7cd: no findings.

Silthus and others added 4 commits November 13, 2026 15:36
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>
Silthus and others added 6 commits November 13, 2026 15:52
…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>
main equals #451's merged head a090b5b, which this branch already contains, so the tree is unchanged from f98eb3b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus changed the base branch from feat/migrate-email-all-sdks to main November 13, 2026 16:26
Silthus and others added 9 commits November 13, 2026 16:27
…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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 33ed8a4: no findings.

@Silthus
Silthus marked this pull request as draft November 14, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant