Repository navigation
Conversation
Adds the Mailgun variant: how Mailgun sends look in Node.js, Python, Ruby, PHP, Go, Java, HTTP, SMTP, and framework mailers; how each field, template, and webhook event maps to PostHog; and Mailgun's DNS records, template reads, and suppression lists. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Custom headers, v: values and o:tag always stay: no PostHog SDK takes headers or tags yet. The tags rewrite bullet is gone. - if/unless, each and with convert only for booleans, lists and set objects; tracking options next to a template keep the site; a template site moves from, Reply-To or subject only as a fixed value. - Catches stay as narrow as they were in Node.js, Python, Ruby, PHP and Go. - A built from quotes a name with special characters; contentId is the bare file name; text content becomes bytes, since PostHog reads a string as base64. - Every account read names the US and the EU host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ilgun # Conflicts: # context/skills/migrate-email/config.yaml # scripts/lib/tests/migrate-email-skill.test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A branch on Mailgun's 400, or on Mailgun::BadRequest, becomes a branch
on PostHog's validation_error name in every language; other statuses
keep their status checks. Node.js carries name on the thrown error.
- A bare {{name}} inside {{#each}} becomes {{ item.name }}, and {{this}}
becomes {{ item }}.
- sdk-reference.md lists each SDK's EU host setting, Go's
SetAPIBase(mailgun.APIBaseEU) and Rails' api_host included; the region
note sits at the top of account.md.
- mapping.md names the packages a Mailgun send needs that also serve
other code: form-data, mailgun-ruby's ActionMailer delivery method,
PHP's PSR-18 and PSR-7 packages, and the app's HTTP client.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Test against a wizard branch:
Add Results will be posted here when complete. |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Python: a catch of HTTPError, and a moved status check, raise network_error again, as requests and Mailgun's SDK raised it past them. Any language: a status check never saw a network failure, and a site that read no status logs API errors and goes on. - Tracking: work out opens and clicks from the send, else the domain's setting (off for a new domain). Both on moves with no field, both off with captureEngagement: false, mixed or htmlonly stays; a template site with tracking off stays. account.md reads the domain's settings. - Handlebars: names and helper arguments inside each read the item, and nested loops get their own names. - account.md reads a pinned account-template version, asks for the version's From, Reply-To and Subject, and names where the unsubscribe count comes from. - A Ruby client in test mode keeps its site when the app turns it on outside its tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…write throws The Node rule rethrew PostHog's error with only its status, and a network failure has none, so the old catch lost the code that says the request never reached PostHog. Carry error.code as well. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A `to` string from one stored address holds one address; one joined from several, or taken from input or a mail object, counts as several. - A field the Fields table doesn't name, such as `sender`, keeps the site. - A read of the answer's headers, such as a rate-limit header, keeps the site. - sdk-reference.md names the HTTP clients real sends use: RestClient, Net::HTTP, curl_init, Guzzle, HttpClient and RestSharp, and a request built from a base URL and a path. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ose HTTP client threw Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ilgun's error rewrite axios with a permissive validateStatus and got with throwHttpErrors: false answer a failed status without throwing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…-js keeps PostHog's code Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ression list, and name Mailgun's suppression writes Co-Authored-By: Claude Opus 5.5 <[email protected]>
…domains whose sites move Co-Authored-By: Claude Opus 5.5 <[email protected]>
…g, like Ruby's test mode Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…l-mailgun Brings #472's rewrite-<language>.md files through the layer below. posthog-sdks.md is gone; this provider's mapping moves to the rewrite file in the next commit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Replace every posthog-sdks.md reference with the rewrite file for the site's language. cc, bcc, attachment, and inline now stay on Mailgun because no PostHog SDK takes them, and the rewrite bullets that converted them are gone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ilgun Brings #472's Elixir fix (fall back past a nil :posthog host) and each lower provider's move to the rewrite file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings #472's single list of error names in Step 7 and the reply-to rule in each rewrite file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings #472's Step 7 rule: behind a reverse proxy, email gets its own client on PostHog's host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…eat/migrate-email-mailgun
A Mailgun template site now moves by bringing the version's HTML into the code, rendered with its Handlebars semantics, with the version's From, Subject, and Reply-To headers where the site sets none. A template made in PostHog is used only when the person asks for one. What moves that stayed before: t:text (the code writes the text part), triple braces, if/unless on non-boolean values, each with else, @index, @FIRST, @last, ../, with that can be missing or has an else, tracking fields next to a template, and a send to one address with recipient-variables, whose %recipient.<name>% values the code now puts into the subject and content itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…-email-mailgun # Conflicts: # scripts/lib/tests/migrate-email-skill.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A person who sends with Mailgun and asks to move those sends to PostHog gets no help:
migrate-emailcovers Resend and, with #459 and #460, SendGrid and Postmark.With this PR,
migrate-email-mailgunmoves Mailgun sends to PostHog's email send in each language where both Mailgun and PostHog have an SDK, or where the app posts to Mailgun's HTTP API, andwizard migrate mailgunruns it.Important
Stacked on #460. Merge that first. This PR's own changes:
fdb4671...9f1773e.It adds one provider folder, one config variant, one
AGENTS.mdrow and the provider in the bundle test. The shared steps it uses, and their review fixes, come from #459.Like #459, it waits for the PostHog SDK releases with the code-form email send. Nothing merges until code sends are live and usable for customers: Michael's rule for #451 and every PR stacked on it.
Impact
Sends that arrive with
migratedFrom: 'mailgun'show whether people use this skill to move off Mailgun. Read it 14 days after the Wizard runsmigrateas a family.Merge risk
Warning
🟡 Merge risk: medium · two-way door
wizard migrate mailgun, so reach is few. Then the skill edits their send code, and a wrong rewrite harms silently once they deploy: a refusal swallowed, a failure that used to pass now thrown (or the reverse), open or click capture changed, a suppressed address mailed, or a test-mode client that starts sending. Each moved site keeps its error behavior and its tracking, a site stays whenever a domain's tracking or the client's error behavior can't be read, and the report's "Before cutover" list names what the person checks first.git revertthe merge commit. It removes the Mailgun variant.Summary
references/mailgun/holds the three files each variant has:sdk-reference.md: Mailgun's libraries in Node.js (mailgun.jsand the oldermailgun-js), Python, Ruby, PHP, Go (v4 and v5) and Java, the HTTP API with the clients real sends use (requests, RestClient,Net::HTTP, curl, Guzzle,HttpClient, RestSharp), SMTP, and framework mailers. It covers how each reports errors, the setting that points each client at the EU region, the clients that send nothing (Ruby's test mode, Python'sdry_run), the send fields, stored templates, status reads, webhooks, and the APIs that are not sends.mapping.md:recipient-variablesand batch sends,%recipient.*%and unsubscribe variables,o:deliverytimeand the othero:options, customh:headers,v:values ando:tag,amp-html, MIME sends, mailing list recipients, SMTP and framework mailers, and any field the table doesn't name. Sites withcc,bcc,attachmentorinlinestay too, because no PostHog SDK takes them yet. Atostring from one stored address is one address; one the code joins from several counts as several. AnIdempotency-Keymoves when the code bounds its length and characters.o:tracking-opensoro:tracking-clicks, else itso:tracking, else the domain's setting, which Mailgun turns off for a new domain. A field set tonoorfalsecounts as set. Both tracked moves with no field; neither moves withcaptureEngagement: false; one of each,htmlonly, or an unknown domain setting (domain tracking not checked) stays. A template site with tracking off stays.if/else if/unlesson booleans,eachover lists (a bare name or a helper's argument inside it reads the item),withon a value that is always an object, andequal. Triple braces, other helpers,@index, partials,../, agoengine version, and blocks on values that are not always booleans or lists keep it on Mailgun.validateStatusthat accepts a failure, got withthrowHttpErrors: false, and Guzzle with'http_errors' => falseanswer a failed status, asfetchandrequestsdo. A site whose client threw keeps failing on every PostHog error. A site that never read the status logs PostHog's error and goes on, and raisesnetwork_errorthe way the request did. A status check moves into PostHog's error handling. A branch on400readsvalidation_error, and a branch on429skipsquota_limited. Node.js throws an error withstatus,details,nameandcode.Client(..., dry_run=True), keeps its sites when the app turns it on outside its tests. When only the tests turn it on, the sites move and the report names the tests that now need the PostHog send stubbed. A read of the answer's headers keeps the site.$workflows_email_*event. A handler that matches events by message id, av:orX-Mailgun-Variablesvalue, or a tag keeps its sites on Mailgun.account.md: Mailgun's SPF, DKIM, tracking andMXrecords next to PostHog's, a read-only API key that the person deletes at the end of Step 5, and the US or EU host for every read. It reads the template version a site sends (domain and account templates) and the domain's tracking settings. It imports the bounces and complaints of the domains whose sites move, and of no other domain. Unsubscribes are counted and reported, and allowlists are left out, with the reason why.The mapping names PostHog's fields, client and errors through the rewrite file for the site's language. Those files come from #472, which the stack now sits on under #459: one
references/rewrite-<language>.mdper PostHog SDK (Node.js, Python, Go, Ruby, PHP, .NET, Rust and Elixir), and Step 7's single list of what every SDK's send takes. Steps 7 and 8 run each rewrite file's check on the whole project, in place of a typecheck per language. #459 deletesposthog-sdks.md, and the plan'slangcolumn is nowlanguage.Evidence
Lane gate on
9f1773e:bunx pnpm@10 testpasses 255 tests in 29 files.CI: green, 7 checks.
Proof gate:
fleet proof gateat9f1773e:gate: pass (kinds: live-run, prod-shape; review: carried from 4260cda; CI: green; judge: fail accepted by standing rule). The judge's gap is recorded under the standing rule: the dry run is the proof form the brief names, and an applied rewrite with typecheck and send can't run until the PostHog SDK releases with the code-form email send are out.Final read at
4260cda: GPT-6.1 Sol read this round's change,7a4a8a6and the forward merges below it. One finding, answered:migrate-email-skill.test.js(line 146) checks the reply-to wording of each rewrite file, not the send it produces. Rejected: the assertion is feat(migrate-email): move Resend sends in every PostHog SDK's language #472's (bb711c5), not this PR's. It guards that each rewrite file states its language's reply-to shape, which feat(migrate-email): move Resend sends in every PostHog SDK's language #472 checked against each SDK head, and the build test already fails on an unresolved file reference for every provider. Routed to feat(migrate-email): move Resend sends in every PostHog SDK's language #472's owner as a low.Test seams (
migrate-email-skill.test.js): the Mailgun bundle resolves every reference, holds its three files and the eight rewrite files, noposthog-sdks.md, and no other provider's folder; itsmapping.mdnamesmigratedFrom: 'mailgun'; it is amigratesubcommand.Reused at
9f1773e. This head adds only clean merges of #472, at 5398fd9 and 6924d48 (one test assertion), which leave this PR's own diff unchanged. 5398fd9 changes one Step 7 rule: behind a reverse proxy, email gets its own client on PostHog's host. Every fixture's PostHog host is PostHog's own, so the rule can't change a site, and the dry run and final read at4260cdastand. The lane gate above ran at this head.Dry run at
4260cda(live-run proof).4260cdaadds7a4a8a6, which points the Mailgun mapping at the rewrite file for the site's language in place ofposthog-sdks.md(6 references, for field names, the client, the error under "The answer", and the answer's message id). It turns thecc,bcc,attachmentandinlinerows into stay rows ("Always. PostHog's send doesn't take it yet"), and the template rule now names only theo:trackingoptions. It dropsfiles=from the Python call shape, theccandbccaddress conversion, and the attachment conversion bullet. The row for a language without a PostHog SDK that sends email now names Java or Kotlin and points at Step 7's list. The forward merge below it brings feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files and Step 7's list of what every SDK's send takes. The same headless dry run, on the same fixture, now runs with answers that no longer say an SDK takescc,bccor attachments. The expected outcome was written before the run:receipt.tsstays on cc and attachments, and every other site lands as in the last run. Result: 11 found, 6 moved, 5 left, as expected.receipt.tsstays with the reason "ccandattachment, which PostHog's send doesn't take yet". The plan uses thelanguagecolumn, the run never namesposthog-sdks.md, and the fixture was unchanged.Dry run on
8667a82. Claude Code on Sonnet 5.5 ran the built skill as a dry run, headless, with only read tools, on a fixture app with eleven Mailgun sends in TypeScript, Python, Go and PHP, a bounce list read, three suppression writes, and a webhook route. The person's answers were in the prompt (two sending domains,mail.acme.devtracking both on andnotify.acme.devboth off, no shared suppression list); the expected outcomes were written before each run. The fixture was unchanged after the run.welcome.ts:Name <address>into, catch readserr.statusanderr.detailsstatusanddetails, plusnameandcodereceipt.ts:tolist of one,cc, a PDF attachmentccand the attachment. Logsdata.idwhere it logged Mailgun'sid(stays from4260cda: cc and attachments)invoice.ts: stored template withif/elseandeach,paidanditemsbuilt by its callertemplateIdandtemplateData. Sends nofromorsubject: the template holds themdigest.ts:recipient-variablesto, plusrecipient-variablesand%recipient.name%trial.ts:o:deliverytimeando:tago:tag, alsoo:deliverytimepassword_reset.py: checksresponse.status_codeexcept EmailError. Raisesnetwork_erroragain, then therecipient_suppressedbranch, then returnsFalseas beforeexport_ready.py:v:export_idinvite.py:requestspost,frombuilt from the inviter's name,raise_for_status()distinct_id, since the invitee may not be a user yet. The display name is always double-quoted. Every error butrecipient_suppressedraises, as beforealert.go: v5,UnexpectedResponseErrorerrors.Ason*posthog.EmailError;network_errortakes the old pathweekly.go: v4, sends fromnotify.acme.devdomain notify.acme.dev keeps its own suppression listpassword_changed.php: constanth:Reply-To,catch (HttpClientException)reply_to. The catch keepsvalidation_errorand 4xx answers, and throws every other error againThe webhook route is listed: a permanent
failedandcomplainedmap to$workflows_email_bouncedand$workflows_email_blocked. The report asks the person to makeaddBouncealso add to PostHog's list (Synced from Mailgun) andclearBouncealso remove from it under feat(skills): move SendGrid sends to PostHog in every SDK language #459's Step 5 check, and leaves the newsletter unsubscribe a Mailgun write. Step 5 namesmail.acme.dev's lists only. Summary block:from: mailgun,sites_found: 11,sites_rewritten: 7,sites_left: 4, as expected.Two fixture additions were made with their expected outcomes written first. The person's answers now decline a shared suppression list, which keeps
weekly.go(an earlier run moved it withcaptureEngagement: falsefrom its domain's setting). And a caller that buildspaidanditemswas added forsendInvoice: without it, the run at926c9cekept the invoice as feat(skills): move SendGrid sends to PostHog in every SDK language #459's strict rule on unchecked template values says.Prod-shape. A survey of real codebases' Mailgun send shapes (saved at
fa9e83e, reused: the commits after it are review fixes to the tracking, error, suppression and test-client rules, the forward merge of feat(skills): move Resend sends to posthog-node in migrate-email #451's Step 6 key check, and feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files). It added thetorule for one stored address, the stay for unnamed fields and for reads of the answer's headers, the HTTP clients real sends use, and three fixture sites: a plain HTTP post with afrombuilt at run time, the v4 Go call form, and PHP with a constant Reply-To.Gaps:
mailgun-js.captureEngagement: false; mixed tracking,htmlonly, and an unknown domain setting;Idempotency-Key;inlinefiles; Handlebarsunless,withandequal, agoengine version, and triple braces; Ruby's test mode and Python'sdry_run; axios, got and Guzzle options that turn the throw off, and RestSharp; a branch on429; the EU host; MIME and mailing list sends; the unsubscribe count from an export or the API. A fixture for these is a follow-up.Decisions
htmlonlyor unknown stays, sincecaptureEngagement: falseturns off both. Replaced a rule that kept everyyesortruefieldgoengine version keeps its site on Mailgunnetwork_erroras beforeexcept EmailError, which raisesnetwork_erroragain firstvalidation_errorfor a400, or429withoutquota_limited, and raises the rest, never widerdry_runkeeps its sites when the app turns it on outside its tests; when only tests do, the sites move and the report names the tests to stubapi.mailgun.netorapi.eu.mailgun.net, told by the client's URL or host settingInvolvement: qa-swarm, 2 panel rounds (round 1: fresh-eyes, correctness, risk, design, anti-overengineering, sweep; round 2: correctness, fresh-eyes, risk, sweep) and 14 final reads, the last at
4260cda, with 1 finding on #472's files, answered. Proof captured: 21 dry runs over the review, the last at4260cda, and a prod-shape survey.🤖 Agent context
Agent: Claude Code, Claude Opus 5.5 (
claude-opus-5-5, high), as the migration-skills slice of Silthus/posthog#461. Reviews on GPT-6.1 Sol (high), the fresh-eyes lens on Opus 5.5; fixes on Opus 5.5 (high). Dry runs on Claude Sonnet 5.5 (claude-sonnet-5-5).🤖 Generated with Claude Code