Skip to content

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

Draft
Silthus wants to merge 46 commits into
feat/migrate-email-sendgridfrom
feat/migrate-email-postmark
Draft

Silthus wants to merge 46 commits into
feat/migrate-email-sendgridfrom
feat/migrate-email-postmark

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown

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 #459. Merge that first. This PR's own changes: a8d0023...fdb4671.
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: '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

Warning

🟡 Merge risk: medium · two-way door

  • The new files run only when a person picks wizard migrate postmark, so reach is few. Then the skill edits their send code, and a wrong rewrite harms silently once they deploy: a refusal swallowed, open or link capture changed, or a suppressed address mailed. Each moved site keeps its error behavior and its tracking, a site stays whenever a server's tracking or list 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 Postmark variant.

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, 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 batch call with a written list of exactly one message is one send.
    • 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 sendEmailModel sends TrackOpens: false), or the server's setting. Both tracked moves with no field; neither moves with captureEngagement: false; one of each, or an unknown server setting (server tracking not checked), stays. A template site with either untracked stays.
    • 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.
    • Templates. A template send moves only to a template the person makes in PostHog. Mustachio becomes Liquid: values, boolean sections, each over lists, ../ to top-level keys, and the layout's {{{ @content }}}. Raw-HTML braces, pm:unsubscribe, and sections on values that are not always booleans or lists keep it on Postmark.
    • Errors. Never widen a catch. ApiInputError, a 422, ValidationException and InvalidEmailRequestError read validation_error; the last two exclude recipient_suppressed, which they never covered. A catch of Postmark's API errors only raises network_error again. 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 handler that matches events by MessageID, Metadata or Tag keeps its sites on Postmark.
    • Suppression writes. An app call that adds to or reactivates an address on the outbound suppressions keeps its Postmark write and also writes PostHog's list, as feat(skills): move SendGrid sends to PostHog in every SDK language #459's Step 5 says, until the server's last send moves.
  • account.md: Postmark's DKIM and Return-Path records next to PostHog's, a migration token per server for templates, tracking settings and suppressions, and the import of the outbound stream's suppressions (hard bounces, spam complaints, manual suppressions). Broadcast streams and other transactional streams 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>.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 fdb4671: bunx pnpm@10 test passes 254 tests in 29 files. bunx pnpm@10 run build builds migrate-email-postmark.zip, and skill-menu.json cliEntries lists postmark under migrate, next to resend, sendgrid and statsig.

  • CI: green, 7 checks.

  • Proof gate: fleet proof gate at fdb4671: gate: pass (kinds: live-run, prod-shape; review: carried from ab933ea; 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 ab933ea: GPT-6.1 Sol read this round's change, 74f1efd and the forward merges below it. One finding, answered:

  • Test seams (migrate-email-skill.test.js): the Postmark 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: 'postmark'; it is a migrate subcommand.

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

  • Dry run at ab933ea (live-run proof). ab933ea adds 74f1efd, which points the Postmark mapping at the rewrite file for the site's language in place of posthog-sdks.md (10 references, for field names, the client, the async Python client, the synchronous .NET send, and the error under "The answer"). It turns the Cc, Bcc and Attachments rows into stay rows ("Always. PostHog's send doesn't take it yet") and names them in the template rule among the fields that keep any site. It drops the rewrite bullets that split Cc and Bcc into lists (the ReplyTo split stays) and converted an attachment, with its base64 decode in Go and Elixir and its bare contentId. 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: 12 found, 6 moved, 6 left, as expected. receipt.ts stays with the reason "cc and attachments". The plan uses the language column, the run never names posthog-sdks.md, and the fixture was unchanged.

  • Dry run on 8bc3253. Claude Code on Sonnet 5.5 ran the built skill as a dry run, headless, with only read tools, on a fixture app with twelve Postmark sends in TypeScript, Python, Ruby, PHP and C#, plus a webhook route. The person's answers were in the prompt (one server, open and link tracking on); the expected outcomes were written before each run. The fixture was unchanged after both runs.

    Site Expected Result
    welcome.ts: catch reads statusCode and code moves moved. Throws an error with statusCode, PostHog's string code and name; MessageStream: 'outbound' dropped
    receipt.ts: Cc and a PDF attachment moves moved with cc and the base64 attachment. Logs data.id where it logged MessageID (stays from ab933ea: cc and attachments)
    invoice.ts: template alias with a layout, paid and items built by its caller moves to the person's PostHog template moved with templateId and templateData. Sections, each and the layout listed as converted
    digest.ts: sendEmailBatch stays stays: batch send
    trial.ts: Tag and Metadata stays stays: tags, and the webhook matches clicks by both
    notice.ts: HTTP, a caller passes the broadcast stream stays stays: MessageStream broadcast
    password_reset.py: async, InactiveRecipientException, then PostmarkAPIException moves moved to AsyncPosthog. One except EmailError: raises network_error again, then the recipient_suppressed branch, then the general one
    verify_email.py: HTTP with an ErrorCode check moves moved. except EmailError raises the same RuntimeError, and lets network_error through as the httpx error went
    usage_alert.rb: rescue Postmark::ApiInputError moves moved. Rescues PostHog::EmailError and raises unless its name is validation_error, which also covers a suppressed recipient
    team_report.rb: bcc stays stays: posthog-ruby has no bcc
    signup.php: sendEmailBatch([$message]), reads ErrorCode moves as one send moved. Catches \PostHog\EmailError, throws network_error again, logs a validation_error as refused
    AlertSender.cs: positional SendMessageAsync stays stays: the .NET SDK sends TrackLinks: None while the server tracks opens, and captureEngagement: false would turn off both

    The webhook route is listed: Bounce and SpamComplaint by address map to $workflows_email_bounced and $workflows_email_blocked, and the report names the webhook destination. Summary block: from: postmark, sites_found: 12, sites_rewritten: 7, sites_left: 5, as expected.

    The two runs differ on one site, by fixture, not by model. The first run (run-8bc3253) had no caller of sendInvoice in the fixture, and kept the invoice with "paid is not checked to be a boolean, and items is not checked to be a list" (12/6/6), as feat(skills): move SendGrid sends to PostHog in every SDK language #459's strict rule says. A caller that builds both values was then added, with its expected outcome written first, and the second run (run-8bc3253-j) moved the invoice (12/7/5). The other eleven sites match in both.

  • Prod-shape. A survey of real codebases' Postmark send shapes (saved at a287f14, reused: the commits after it change only tracking, error and suppression rules, which the dry run at this head covers, 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 the batch of one, the .NET positional forms and their text-before-HTML order, and the removal of Status and ErrorCode checks after a send that throws.

  • 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, Ruby and PHP, and classifies a .NET site that stays. No run rewrites a .NET or Go site.
    • Rule paths without a fixture site: server tracking off for both, unknown, or untracked on a template site; postmarker sends; PHP's sendEmailModel default; a narrow InvalidEmailRequestError or ValidationException catch; several servers sharing one suppression list; app calls that add to or reactivate the outbound suppressions. 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
Which streams' suppressions move Only outbound, the stream the moved sites send through. Broadcast streams are marketing mail, and other transactional streams stay with their sites 🤖 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, since PostHog would refuse addresses another server suppressed 🤖 Agent this PR
Postmark tracking Work out opens and links from the field, the SDK default, or the server; mixed or unknown stays, since captureEngagement: false turns off both. Replaced a plan-row note that let PostHog start capturing what Postmark didn't 🤖 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; InvalidEmailRequestError and ValidationException exclude recipient_suppressed 🤖 Agent this PR
A batch call with one written message One send. A refused message is logged as before; every other failure is raised, as the batch call did 🤖 Agent this PR
Suppression dump paging and export format No paging, and the dashboard export is JSON, as Postmark's docs say. Two review findings refused 🤖 Agent this PR
Behavior tests for tracking and suppression sync No vitest seam runs an agent; the dry run is the proof, and the uncovered paths are listed under Gaps. Refused as unit tests under the standing rule 🤖 Agent this PR
cc, bcc and attachments Stay on Postmark 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. No shadow diff: this PR's own diff changes no shared step 🤖 Agent this PR
Runtime proof the judge asked for Accepted as a gap under the standing rule: the dry run is the brief's proof form, and an applied rewrite with typecheck and send waits for the SDK releases 🤖 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, had one low on #472's test file, answered under Evidence 🤖 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) and 13 final reads, the last at ab933ea, with 1 finding on #472's files, answered. Proof captured: 19 dry runs over the review, the last at ab933ea 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 6 commits November 4, 2026 07:27
Add the postmark variant of migrate-email: the SDK reference for the
official Node.js, Python, Ruby, PHP, .NET and Java SDKs and the community
Python and Go packages, the field, call, template and webhook mapping,
and the account file with Postmark's DNS records, template reads and
per-stream suppression import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Headers, Metadata and Tag always stay: no PostHog SDK takes headers
  or tags yet. The tags rewrite bullet is gone.
- Sections convert to Liquid if/unless only for booleans, null or
  missing values, and each converts to for only for lists.
- Tracking switches next to a template keep the site.
- Go and Elixir decode the base64 attachment content first; contentId
  is a bare name.
- Never widen a catch, per language, including the network errors that
  Postmark's API-only catches let through.
- No claims about PostHog beyond posthog-sdks.md and the steps.

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 3 commits November 4, 2026 08:08
- An invalid-send branch (ApiInputError, ValidationException,
  InvalidEmailRequestError, or a 422) becomes a branch on PostHog's
  error name validation_error in every language, never a status; the
  Node.js rewrite carries name on the thrown error.
- A bare name inside {{#each}} reads the current item: {{ item.name }}.
- The Postmark package stays for Postmark's other APIs and for
  postmark-rails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Silthus and others added 19 commits November 4, 2026 08:48
- InvalidEmailRequestError and Python's ValidationException never
  covered inactive recipients: their branch now raises
  recipient_suppressed first instead of catching it as validation_error.
- A one-entry To list moves as that entry.
- Inside {{#each}}, a top-level key needs ../../, as Postmark documents.
- A send with no tracking field followed the server's settings, and
  postmarker never tracked links: the plan row notes that PostHog now
  captures opens and clicks.
- Suppressions are imported from every server whose sites move, and
  broadcast-stream lists are no longer counted.
- An app call that adds outbound suppressions is reported, so the person
  moves it before the deploy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A batch call with a written list of one message is one send; its
  refusal came back in the answer's ErrorCode, so the rewrite reads
  PostHog's error instead.
- TrackOpens: true or link tracking next to a template is dropped, as
  the Fields table says; only the off values keep the site.
- .NET's positional SendMessageAsync and PostmarkMessage take the text
  body before the HTML body; SendMessage is the synchronous shim.
- A Status or ErrorCode check after a single .NET or PHP send only saw
  successes, since both clients throw on any answer but 200.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ns and links

captureEngagement: false turns off both, so such a site stays, as SendGrid's and Mailgun's do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
PostmarkMessageBase's constructor sets TrackLinks to None, so a .NET send tracks links only when the code sets it, as postmarker's sends do. The rewrite adds captureEngagement: false only when Postmark tracked neither.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ull follows the server

Only an SDK message whose code never sets TrackLinks keeps the constructor's None; null and plain HTTP sends follow the server's setting.

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

The positional SendMessageAsync extension builds a PostmarkMessage, so its links were not tracked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…send leaves TrackOpens out

Only TrackLinks has an SDK default of None in postmarker and .NET; TrackOpens defaults to null in both and follows the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…from PostHog's suppression list

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Silthus and others added 12 commits November 4, 2026 16:21
…l-postmark

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 Postmark mapping now sends the agent to the rewrite file for the site's language instead of the deleted posthog-sdks.md.
Cc, Bcc, and Attachments now always stay on Postmark, because no PostHog SDK takes them yet. The rewrite bullets that converted them are gone.

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

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]>
A Postmark template send now moves by bringing the template, inside its
layout, into the code and rendering TemplateModel there. A template made
in PostHog is used only when the person asks for one.

Moves now that stayed before: raw {{{x}}} and {{& x}} values, sections
on any value that is never a list, tested with Mustachio's truth test,
inverted sections used as fallbacks, ../ to any outer scope, and
template sends with opens or links untracked. The email carries the
CSS that InlineCss inlined.

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