Skip to content

feat(skills): move Mandrill sends to PostHog - #467

Draft
Silthus wants to merge 54 commits into
feat/migrate-email-mailjetfrom
feat/migrate-email-mandrill
Draft

Silthus wants to merge 54 commits into
feat/migrate-email-mailjetfrom
feat/migrate-email-mandrill

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown

Problem

A person who sends with Mailchimp Transactional, formerly Mandrill, and asks to move those sends to PostHog gets no help: migrate-email has no Mailchimp Transactional variant.

With this PR, migrate-email-mandrill moves Mailchimp Transactional sends to PostHog's email send in each language where PostHog has one, whether the app uses the official SDKs, the older mandrill clients, a community client or the HTTP API, and wizard migrate mandrill runs it.

Important

Stacked on #466. Merge that first. This PR's own changes: bba2c48...e1a4d44.
It adds one provider folder, one config variant, one AGENTS.md row 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: 'mandrill' show whether people use this skill to move off Mailchimp Transactional. Read it 14 days after the Wizard runs migrate as a family.

Merge risk

Warning

🟡 Merge risk: medium · two-way door

  • The new files run only when a person picks wizard migrate mandrill, so reach is few. Then the skill edits their send code, and a wrong rewrite harms silently once they deploy: a rejected or malformed recipient that used to pass quietly now fails the send, open or click capture changes, or a denylisted address gets mail. Each moved site keeps its error behavior, a malformed address is checked before the send where the site went on, a site stays whenever the account's Sending Defaults can't be checked, and the report's "Before cutover" list names what the person checks first.
  • No shared step, no other provider, and no build code changes in this PR's own diff.
  • Revert: git revert the merge commit. It removes the Mailchimp Transactional variant.

Summary

references/mandrill/ holds the three files each variant has:

  • sdk-reference.md: the official packages in Node.js, Python, Ruby and PHP, the older mandrill clients, community clients in Go, .NET and Elixir, the HTTP API, SMTP and framework mailers. It covers how each reports an error: the Node.js and PHP SDKs answer the error in place of the list, the others raise, and a PHP connection error follows the Guzzle version in composer.lock (answered with Guzzle 6, thrown with Guzzle 7). Every SDK answers a refused recipient as rejected or invalid in a successful answer. It also covers the send fields, stored templates and merge languages, status reads, signed webhooks, and the APIs that are not sends.
  • mapping.md:
    • Fields. Each field maps to PostHog's or names what keeps a site on Mailchimp Transactional: more than one to entry or none, headers other than Reply-To, tags, metadata, subaccount, send_at, ip_pool, custom tracking and signing domains, inline_css, important, merge tags in inline content, send-mc-template, send-raw, SMTP and framework mailers. Sites with cc or bcc entries, bcc_address, attachments or images stay too, because no PostHog SDK takes them yet. A send through the older PHP client mandrill/mandrill, or a copy of it kept in the app's code under other names, stays: its source is no longer published, so how it failed can't be read.
    • Sending Defaults. A field the site leaves out takes the account's default for tracking, inline CSS and HTML generation, and the site is classified with that value. The API can't read the defaults, so the person gives them; when they can't, a site that leaves out tracking stays with account tracking not checked.
    • Tracking. Opens and clicks are worked out per site from the send's fields or the Sending Defaults. Both tracked moves with no field; neither moves with captureEngagement: false; one of each stays, since captureEngagement: false turns off both. A template site with either off stays.
    • Templates. A template send moves only to a template the person makes in PostHog. The Mailchimp merge language and Handlebars become Liquid: values, boolean IF and if, string compares, each over lists, with a bare name inside the loop reading the item. Raw-HTML tags, a template_content entry (an mc:edit region), html or text next to the template, other merge tags and helpers, and tests on values that are not always booleans, strings or lists keep it on Mailchimp Transactional. An empty template_content, [] or [{}], moves. Merge var names that differ only in case are one variable.
    • Errors. Never widen a catch; a catch of every error stays as it is. A rejected recipient becomes recipient_suppressed, handled the way the site handled it, and a branch on one reject_reason moves only when the site treats hard-bounce, spam and custom the same. A branch on ValidationError reads validation_error, after recipient_suppressed; a branch on an invalid recipient alone, or a site that went on for one, becomes a check of the address before the send, since validation_error also covers other invalid sends. Python and Ruby raise network_error again, which the old catch never saw.
    • Webhooks. Each event maps to a $workflows_email_* event. A handler that matches events by the send's _id, or a site that sets metadata, keeps its sites on Mailchimp Transactional.
    • Suppression writes. A main-account rejects.add keeps its Mailchimp Transactional write and also writes PostHog's list, and rejects.delete removes from PostHog's list where feat(skills): move SendGrid sends to PostHog in every SDK language #459's Step 5 allows it, until the last main-account send moves.
  • account.md: Mailchimp Transactional's DKIM, verification and DMARC records next to PostHog's, a read-only key limited to templates/info, templates/list and rejects/list, the Sending Defaults to ask for, and a template's published version. The suppression import takes the main account's unexpired hard bounces, spam complaints and custom entries from the Rejection Denylist. It leaves out subaccount entries (their sends stay), soft bounces, expired entries and unsubscribes, 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>.md per 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 deletes posthog-sdks.md, and the plan's lang column is now language.

Evidence

  • Lane gate on e1a4d44: bunx pnpm@10 test passes 261 tests in 29 files. bunx pnpm@10 run build builds migrate-email-mandrill.zip, and skill-menu.json cliEntries lists mandrill under migrate, next to the other providers and statsig.

  • CI: green, 7 checks.

  • Proof gate: fleet proof gate at e1a4d44: gate: pass (kinds: live-run, prod-shape; review: carried from f8bc637; CI: green; judge: pass by gpt-6.1-sol).

  • Final read at f8bc637: GPT-6.1 Sol read this round's change, 7813bb2 and the forward merges below it: clean.

  • Test seams (migrate-email-skill.test.js): the Mailchimp Transactional bundle resolves every reference, holds its three files and the eight rewrite files, no posthog-sdks.md, and no other provider's folder; its mapping.md names migratedFrom: 'mandrill'; it is a migrate subcommand.

  • Reused at e1a4d44. 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 at f8bc637 stand. The lane gate above ran at this head.

  • Dry run at f8bc637 (live-run proof). f8bc637 adds 7813bb2, which points the Mailchimp Transactional mapping at the rewrite file for the site's language in place of posthog-sdks.md (8 references). It turns the rows for to entries of type cc and bcc, bcc_address, attachments and images into stay rows ("Always. PostHog's send doesn't take it yet"), so a cc no longer moves with preserve_recipients: true. The template rule now names only tracking that was off. It drops the cc and bcc conversion and the attachment and image 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 takes cc, bcc or attachments. The expected outcome was written before the run: receipt.ts stays on cc and attachments, and every other site lands as in the last run. Result: 11 found, 4 moved, 7 left, as expected. receipt.ts stays with the reason "cc recipient and attachments". The plan uses the language column, the run never names posthog-sdks.md, and the fixture was unchanged.

  • Dry run on 5811e0e. 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 Mailchimp Transactional sends in TypeScript, Python and Ruby, plus a messages.info status read and a webhook route. The person's answers were in the prompt (Sending Defaults with open and click tracking on); the expected outcomes were written before each run. The fixture was unchanged after the run.

    Site Expected Result
    welcome.ts: throws a non-list answer, went on for a refused recipient moves moved. Checks the address before the send (log and skip), logs and returns on recipient_suppressed, and throws an error with statusCode, name and code. The To line loses the name
    receipt.ts: cc with preserve_recipients: true and a PDF attachment moves moved with cc and the base64 attachment. Logs data.id where it logged _id (stays from f8bc637: cc and attachments)
    invoice.ts: sendTemplate with Handlebars, paid and items built by its caller moves to the person's PostHog template moved with templateId and templateData. if/else and each listed as converted
    digest.ts: several to entries stays stays: several recipients
    trial.ts: send_at stays stays: send_at
    password-changed.ts: tags stays stays: tags
    password_reset.py: ApiClientError, then a rejected or invalid check moves moved. One except EmailError: raises network_error again, returns False on recipient_suppressed and every other error, as before
    weekly_digest.py: older mandrill client with metadata stays stays: metadata
    usage_alert.rb: rescue MailchimpTransactional::ApiError moves moved. Checks the address first, rescues PostHog::EmailError, raises network_error again, returns on recipient_suppressed, and raises the rest as before
    team_report.rb: bcc stays stays: posthog-ruby has no bcc
    account_update.rb: send_template with a template_content entry stays stays: the entry fills an mc:edit region

    The status read stays and the report says it can't find moved sends. The webhook route matches by address, so it keeps no site: hard_bounce and spam map to $workflows_email_bounced and $workflows_email_blocked, and the report names the webhook destination. Summary block: from: mandrill, sites_found: 11, sites_rewritten: 5, sites_left: 6, as expected.

    One earlier run missed a rule, by variance. Of the two runs at 0d08508, one left out the address check on welcome.ts; the other, and every run since, applies it to both sites that went on for an invalid recipient.

  • Prod-shape. A survey of real codebases' Mailchimp Transactional send shapes (saved at 733e0e3, reused: the commits after it are review fixes, forward merges and feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files, which the dry run covers). It added [{}] as an empty template_content, the rule that a catch of every error stays as it is, the copy of the older PHP client kept in an app's code, and fixture sites for the commonest reasons a real site stays: tags, metadata, a template_content entry and the older Python client.

  • Gaps:

    • No run applies the rewrite, typechecks or sends. The SDK capabilities are stated in the person's answers, not read from an installed release. That waits for the SDK releases.
    • The dry run moves sites in Node.js, Python and Ruby on the official SDKs, and classifies a site on the older Python client that stays. No run covers PHP, Go, .NET, Elixir, HTTP calls, the older Node.js mandrill-api callbacks or community clients.
    • Rule paths without a fixture site: Sending Defaults with tracking off, mixed or unknown, and with inline CSS or HTML generation on; a branch on ValidationError, on one reject_reason, or a 429 retry; subaccount sends; rejects.add and rejects.delete calls; the Guzzle 6 and 7 split; [{}]; merge var names that differ in case; the Mailchimp merge language (the fixture's template is Handlebars); the suppression import. A fixture for these is a follow-up.
    • No run with the PostHog MCP connected, and none inside the Wizard.

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
Stack shape One PR per provider in a row, on top of #459 🤖 Agent #459
A recipient answered as rejected Counts as an error of the send: it becomes recipient_suppressed, handled the way the site handled the rejected recipient 🤖 Agent this PR
A recipient answered as invalid A site that branched on it, or went on for it, checks the address before the send and logs and skips. Replaced mapping invalid to validation_error, which also covers other invalid sends 🤖 Agent this PR
A branch on one reject_reason Moves only when the site treats hard-bounce, spam and custom the same, since PostHog answers all three as recipient_suppressed 🤖 Agent this PR
A narrow catch on one Mailchimp Transactional error ValidationError becomes a check of PostHog's validation_error, a status such as 429 a check of PostHog's status code, and other error names keep the site, never a wider catch 🤖 Agent this PR
Network errors Python and Ruby raise network_error again, which their old catch never saw; PHP follows the Guzzle version; a catch of every error stays as it is 🤖 Agent this PR
Account Sending Defaults An unset field takes the account's default. The person gives the defaults, since the API can't read them; unknown tracking keeps the site with account tracking not checked 🤖 Agent this PR
The older PHP client mandrill/mandrill Its sites stay, including copies kept in the app's code: its source is no longer published, so the rewrite can't tell how a site failed 🤖 Agent this PR
cc Stays from f8bc637, as the cc, bcc and attachments row says. Before, it moved only with preserve_recipients: true, since otherwise the account's default decided what recipients saw 🤖 Agent this PR
Which denylist entries move The main account's unexpired hard bounces, spam complaints and custom entries. Subaccount entries stay with their sends, which stay; soft bounces and expired entries are sent to again; unsubscribes are counted in the report 🤖 Agent this PR
Sites with tags or metadata Stay. Whether anything reads them can't be seen in the code, and dropping a field would be a call for every provider. A prod-shape finding refused 🤖 Agent this PR
cc, bcc and attachments Stay on Mailchimp Transactional in every language, since no PostHog SDK takes them yet (#472). Replaced moving them where the installed SDK took them 🤖 Agent this PR
Proof kinds Live run and a survey of real codebases' send shapes, added after round 1 asked for production input shapes. No shadow diff: this PR's own diff changes no shared step 🤖 Agent this PR
More final reads after round 2 Continued without escalating, under the standing rule: each read found a new, narrower issue and none came back after its fix. The last read, at this head, is clean 🤖 Agent this PR

Involvement: qa-swarm, 2 panel rounds (round 1: fresh-eyes, correctness, risk, design, anti-overengineering, sweep; round 2: correctness, fresh-eyes, risk, sweep, anti-overengineering) and 17 final reads, the last at f8bc637, clean. Proof captured: 18 dry runs over the review, the last at f8bc637, 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

Silthus and others added 9 commits November 4, 2026 07:28
Add the mandrill variant of migrate-email: how Mailchimp Transactional
(formerly Mandrill) looks in code, how each send field, template, error
style, and webhook maps to PostHog, and its DNS records, templates, and
rejection denylist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nsactional

- headers, tags and metadata always stay: no PostHog SDK takes them yet
- if/unless/IF convert only for booleans, IF:NAME=value only for strings,
  each only for lists
- tracking switches next to a stored template keep the site
- Go and Elixir decode base64 attachments; quote special from names
- catches stay narrow per language, and network_error is raised again
  where the old catch never saw network errors
- auto_text moves: PostHog makes the text part from html
- drop claims about PostHog that the shared files don't make

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…drill

# Conflicts:
#	context/skills/migrate-email/config.yaml
#	scripts/lib/tests/migrate-email-skill.test.js
…actional

- A branch on ValidationError, by name or subclass, or on an invalid
  recipient alone, becomes a branch on PostHog's validation_error name,
  after recipient_suppressed. Other status checks such as 429 stay. The
  Node.js throw carries name too.
- A bare {{name}} inside {{#each}} reads the item: it becomes
  {{ item.name }}. {{this}} becomes {{ item }}.
- The client section says the package also serves templates, rejects,
  webhooks, SMS and other APIs, and that Anymail, Swoosh and Bamboo
  serve other providers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@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 19 commits November 4, 2026 08:49
…ctional

- A site that handles hard-bounce, spam and custom rejections apart
  stays: PostHog answers all three as recipient_suppressed.
- Only the main account's denylist entries are imported. Subaccount
  entries apply only to subaccount sends, which stay.
- A template site that also sets html or text stays: Mailchimp sends
  the site's html when the template has none.
- A field the site doesn't set takes the account's Sending Defaults
  (tracking, inline CSS, HTML generation). account.md asks for them,
  and tracking off becomes captureEngagement: false.
- PHP's network error follows the Guzzle version: answered in Guzzle 6,
  thrown in Guzzle 7.
- Sites on the older PHP mandrill/mandrill client stay: its source is
  no longer published.
- A site that went on for an invalid recipient but fails on any error
  is noted: a malformed address now fails.
- Merge var names that differ only in case are one variable.
- The mandrill npm package sits with the community clients.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ewrite 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]>
…de showed

- template_content entries with no name, such as [{}], fill no region:
  the site moves like one with [].
- A catch of every error around the send stays as it is and raises no
  network_error again: it caught network errors before too.
- A copy of the older PHP client kept in the app's own code, even under
  other class names, stays like the package.
- The metadata row names metadata, not tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… rules to Mandrill, list rejects.add as a suppression writer

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Silthus and others added 26 commits November 4, 2026 13:08
…dress or an unchecked curl failure, and mirror reject removals

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…n invalid-recipient branch becomes an address check

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t branch into the address check

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…l-mandrill

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]>
The mapping now names the rewrite file for the site's language in place of the deleted posthog-sdks.md.
cc and bcc entries, bcc_address, attachments, and images now stay on Mailchimp Transactional, because no PostHog SDK takes them yet.
The rewrite bullets that converted them, and the template rule that listed them, are gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ndrill

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]>
… code

A send-template site now moves by bringing the published template into
the code: the code renders its merge tags in either merge language,
fills its mc:edit regions from template_content, and sends the rendered
subject, html, text, and from, with the site's own subject and sender
winning as Mailchimp Transactional does. A template made in PostHog is
used only when the person asks for one.

- A messages.send site with merge tags in its own content moves: the
  code renders the values before the send.
- template_content, raw values, comparisons, each over objects,
  @index and ../, with, upper/lower/url/striptags/date, CURRENT_YEAR,
  DATE, EMAIL, and send-mc-template sites now move.
- Unsubscribe tags, title case, partials, and tags the docs don't
  describe still keep a site on Mailchimp Transactional.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…email-mandrill

# Conflicts:
#	scripts/lib/tests/migrate-email-skill.test.js
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