Skip to content

feat(skills): move SparkPost sends to PostHog - #468

Draft
Silthus wants to merge 769 commits into
mainfrom
feat/migrate-email-sparkpost
Draft

Silthus wants to merge 769 commits into
mainfrom
feat/migrate-email-sparkpost

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 SparkPost and asks to move those sends to PostHog gets no help: no migrate-email variant covers SparkPost.

With this PR, migrate-email-sparkpost moves SparkPost sends to PostHog's email send in each language where both SparkPost and PostHog have an SDK, and wizard migrate sparkpost runs it. Sends PostHog can't take yet stay on SparkPost, with a reason in the migration report.

Important

#472, the core migrate-email skill, has merged, and this PR 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's PROVIDERS, conflict in one line each as they land.

Impact

Sends that arrive with migratedFrom: 'sparkpost' show whether people use this skill to move off SparkPost. 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
Tier: critical

  • Once merged, migrate-email-sparkpost ships with the next context-mill release, and every person who runs wizard migrate sparkpost gets it. A wrong instruction in the skill silently changes how the agent rewrites their sends, such as a refusal swallowed, a deadline changed, or an unsubscribed address mailed, in a diff the person may not catch in review.
  • Every other skill is unchanged: the shadow diff against main shows only the new migrate-email-sparkpost bundle and its manifest and skill-menu entries.
  • Uncovered: the live run, an agent migrating a real SparkPost app, waits on the released email SDKs and the production send API, and prod-shape has no store of real customer codebases to sample; the planned trial on generated codebases covers both after the SDKs land. Each rule keeps a site on SparkPost when a setting can't be checked, and the report's "Before cutover" list names what to check first.
  • Switch-off: none. Live with the release; a revert only stops new runs. A migration an agent already ran keeps its rewritten sends, sender, imported suppressions, and key in that app and its PostHog account.

Rollout

  • Stages: ships with the next context-mill release.
  • Decided: not before code sends are live and usable for customers, as the Decisions table says.
  • Watch: no alert covers a wrong rewrite. Sends with migratedFrom: 'sparkpost', as Impact says.

Summary

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

  • sdk-reference.md: SparkPost's libraries in Node.js (2.x, and the 1.x transmissionBody and callback shape with its key renames), Python, Ruby, PHP, Go, Java and Elixir, the community .NET library, the HTTP API, SMTP and framework mailers. It covers how each reports errors and picks the EU host, the template language SparkPost also applies to inline content, stored templates, 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 SparkPost: cc or bcc recipients, attachments, inline images, headers, metadata, recipient tags, campaign_id, several recipients or a stored list_id, start_time, sandbox, skip_suppression, IP pools and DKIM keys, amp_html, A/B tests, raw RFC 822 messages, transactional: false, a from on the sandbox domain sparkpostbox.com, SMTP and framework mailers, and branches on a SparkPost error code. override.from and override.reply_to win over the content for inline sends as for templates. An override.from without a name, next to a from with one, takes the name a received email shows, or the site stays as from name not checked: SparkPost's docs don't say which name it shows.
    • Non-transactional sends and the unsubscribe header. A send without options.transactional on an account whose transactional_default isn't true was non-transactional: SparkPost added its List-Unsubscribe header and skipped addresses with a non_transactional entry, such as unsubscribes. It moves only when the person agrees that it may go without the header and reach people who unsubscribed, now or later. A transactional send on an account with transactional_unsub also carried the header, and moves only when the person agrees it may go without it. Otherwise each stays.
    • Tracking. Opens and clicks are worked out per site from the recipient's options, then the transmission's, the template's, and the account's settings. A subaccount can turn opens off. Tracking never maps to captureEngagement: PostHog adds its open pixel to every email, and counts no clicks of a send in the code form, as the core's Step 7 says. A site whose emails had no open pixel moves only when the person agrees that PostHog may add one, as the core's Step 3 asks under "Opens the provider didn't track", and a site where nobody can tell stays. A site that moves with PostHog's pixel stays where some addresses revoked open-tracking consent, where the app can write such an entry, or where a data-msys-revoke-tracking link in mail the account still sends or sent before can write one. Where nobody can check the list or those links, it stays as open tracking consent not checked. One that tracked opens also stays where the account or subaccount minimizes open events.
    • Content and templates. Inline content with SparkPost's template language, and a stored template, move into the app's code by the shared template-to-code.md. The code renders values, or fallbacks, if with SparkPost's truth test, each, snippets and dynamic content, with escaping as SparkPost escaped. Anything the table doesn't place keeps the site, such as a bare name inside each, loop_index, /, or data-msys-unsubscribe, and so does a snippet the app creates, changes, or deletes through SparkPost's API, as the core says for a template the app changes. A template made in PostHog, only when the person asks for one, converts to Liquid. Such a template counts clicks unless it is marked sensitive, so the person marks it sensitive where SparkPost tracked no clicks or nobody knows whether it did, as the core's Step 3 asks, and for an email that carries a secret.
    • Subaccounts. The agent asks which account each key sends from whenever the account has subaccounts. When moved sites span accounts, the person agrees they share PostHog's one suppression list, and each moved idempotency key gets its account as a prefix, since SparkPost scopes keys per subaccount and PostHog per project.
    • Errors. No catch gets wider. A branch on 400 or 422 reads validation_error, a branch on 420 reads quota_limited, a 429 branch reads status 429 with a code other than quota_limited, and a 409 branch reads both idempotency conflicts, concurrent_idempotent_requests and invalid_idempotent_request. A PHP ->then($ok, $fail) becomes a try around the send alone, so an exception in $ok never reaches $fail. Python, Ruby and .NET raise network_error again, which SparkPost's error class never caught. A raw HTTP call keeps its deadline, and a site whose limits the SDK can't keep stays, as the core's Step 7 says; the mapping names which SparkPost-era options are deadlines and which limit one stage apart, and leaves axios's and ky's timeout, library defaults, and Ruby's whole-request and partial Net::HTTP limits to the core. A PHP site that settles several SparkPost promises together stays, and so does one that stores the transmission id as a number, or passes it to a column shorter than PostHog's 36-character UUID, or of unknown length, or to a service that expects SparkPost's id, unless the person confirms PostHog's id fits. A UUID column takes it. A send the code can cancel moves only where the PostHog send takes the same cancellation. A site that reads a header of SparkPost's answer stays, except a Retry-After read, which moves to PostHog's retry delay, as the core's Step 7 and Resend mapping say; Node.js's rethrown error carries that delay. Where a PHP adapter answered a refused send with a response, the site's check of it becomes a check of PostHog's exception.
    • Webhooks. Each event type maps to a $workflows_email_* event. A handler that matches events by transmission_id, message_id, rcpt_meta, rcpt_tags, campaign_id or template_id keeps its sites on SparkPost, and so does a handler on an event that has no PostHog event, such as delay, list_unsubscribe or policy_rejection, and a click handler unless the site moves to a PostHog template that is not marked sensitive.
    • Suppression list. The mapping names the entries someone added, Manually Added and Compliance, and SparkPost's calls that add or remove an address, for the core's Step 3 question about sites that rely on the provider's list to stop mailing an address, with the reason relies on SparkPost's suppression list. It asks about the list of each account the sites send from. A send right after the app removes its recipient from SparkPost's list stays.
  • account.md: SparkPost's DKIM, bounce and tracking records next to PostHog's, including the clash when the sending domain itself is a bounce CNAME. It also covers the EU host for every request, a read-only key, the account and subaccount tracking, minimization, transactional_default and transactional_unsub settings, and a template version's content and options, read from the site's own account or a template the primary account shares. The suppression import reads each account's list on its own. It takes bounces, spam complaints, and transactional manual and compliance entries, mapped to PostHog's reason values bounce, complaint and manual, as the core's Step 5 imports them.

Evidence

  • Lane gate on the head: vitest run scripts passes 344 tests, including the core's check that every "Step N says under" pointer in the SparkPost folder names a real section. node scripts/build.js builds every skill, migrate-email-sparkpost included, and leaves the tree clean. The bundle test builds migrate-email-sparkpost and checks that every reference path in it resolves.
  • Shadow diff on the head against main at 8cd7381, feat(migrate-email): move Resend sends in every PostHog SDK's language #472's squash, which it merges: 288 bundles at the base, 289 at the head. Only migrate-email-sparkpost is new, and manifest.json and skill-menu.json add its entries; no other bundle changes. Every shared step in the SparkPost bundle is byte-equal to main's Resend bundle.
  • CI: green on the head now that this PR targets main.
  • Live run: gap. An agent run of this migration on a real app needs the released email SDKs and the production send API. The planned trial on generated codebases runs it after both land. The headless dry runs at earlier heads (9d8e1fe, 8289926) predate the rules that bring templates and inline template language into the code, so they don't cover this head.
  • Prod-shape: gap. The inputs are customer-written send code and config and each person's SparkPost account content, and there is no store of real customer codebases to sample. The planned trial on generated codebases covers it after the SDKs land.
  • Review: QA swarm round 1 at 57d3f73 (fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and two team perspectives), fixed in 6d13262. Round 2 at 6d13262, fixed in 93130dd. After the forward merges of feat(migrate-email): move Resend sends in every PostHog SDK's language #472 and the convergence with its shared rules, final reads by fresh-eyes (Opus) and correctness (GPT-6.1 Sol), and from 2184cff on by Fleet's single final reader (GPT-6.1 Sol), ran on each fix until 5ef962b came back clean. PostHog Review: 3 should-fix on 93130dd and 3 on 832fbe3, all fixed; 2 should-fix on 115ec8c, fixed in a8d2b65; 3 should-fix on b87374f, fixed in cdd83b0; 1 on 2184cff, fixed in 51148ce; 3 on 51148ce, fixed in 5ef962b; none on 5ef962b. After feat(migrate-email): move Resend sends in every PostHog SDK's language #472 landed and main merged in at d6f261c: final read 26 clean; PostHog Review 3 should-fix on d6f261c (response headers, Ruby send_message fields), fixed in 9459b57; final read 27 found 1 high (Node.js rethrow dropped the retry delay), fixed in 7df2c7d; PostHog Review 1 on 9459b57 (PHP error answers), fixed in eebfe4e; final reads 28 and 29 clean; PostHog Review 1 on eebfe4e (plain-text part), fixed in 40c9742; final read 30 found 1 high, fixed in c77373c; final read 31 clean; PostHog Review 1 on c77373c (account settings the app can change), fixed in 90bd64a, and 1 on 90bd64a (errors list shape), fixed in 5852c15; final read 32 found 1 high, fixed in f8204a9; final read 33 and PostHog Review on f8204a9 clean.

Live run fixtures

Only the live run tests the installed-client reads, so its fixture app holds one site for each:

  • A Ruby send_message call with positional arguments, and one that also passes content: or recipients:.
  • A PHP send through an adapter that answers a refused send with a response, with a check of its status code, and one through an adapter that throws.

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
SparkPost tracking Never maps to captureEngagement. Opens follow the core's Step 3, "Opens the provider didn't track": a site whose emails had no open pixel moves only on the person's yes, otherwise open tracking off, and one where nobody can tell stays as open tracking not checked. Replaced this PR's earlier rule that opens untracked always stays. Every moved site whose clicks SparkPost tracked, or nobody can check, gets a plan note that PostHog counts none 👤 Human #472
Revoked open-tracking consent A site that moves with PostHog's open pixel, tracked by SparkPost or agreed by the person, stays when the list holds Tracking Consent Revoked entries, or the app can write one 👤 Human this PR
Revoke links A data-msys-revoke-tracking link writes a Tracking Consent Revoked entry, so a site that moves with PostHog's open pixel stays when mail its account still sends, or sent before, carries one, as the row above says for the app; where the person can't say, it stays as open tracking consent not checked. Not chosen: moving on the person's yes for links in old mail, which would waive the row above 🤖 Agent this PR
Stored templates and inline template language Move into the app's code by template-to-code.md; a template made in PostHog only on request 🤖 Agent #472
A send without options.transactional Moves when the account's default is transactional, or the person agrees that people who unsubscribed, now or later, may get it; otherwise stays. Replaced moving it with only a plan-row note. Not chosen: importing non-transactional entries, which would also block sends that SparkPost sent as transactional 🤖 Agent this PR
Idempotency keys across subaccounts Prefix each moved key with its account when moved sites span accounts; a key that can't fit stays 🤖 Agent this PR
Clicks on a template made in PostHog The person marks the template sensitive where SparkPost tracked no clicks, or nobody knows whether it did, as the core's Step 3 asks, or the site stays (click tracking off or click tracking not checked); the core's Step 7 rule that a sensitive email gets no click redirect wins over the Resend wording that a clicked link keeps the site's address 🤖 Agent this PR
A bare name inside each Keeps the template in both paths: SparkPost's docs don't say what it reads 🤖 Agent this PR
Several accounts, one PostHog list Ask whether the moved sites may share one list; if not, only one account's sites move 🤖 Agent this PR
Which suppressions move Bounces, spam complaints, and transactional manual and compliance entries, read per account, as PostHog's bounce, complaint and manual reasons 🤖 Agent this PR
Response headers A site that reads a header of SparkPost's answer stays, except the retry delay, which moves to PostHog's, as the core's Resend mapping does 🤖 Agent this PR
Account settings the app can change A site whose move rests on an account or subaccount setting the app's own PUT /api/v1/account or PUT /api/v1/subaccounts/{id} call can change stays: a later change would no longer reach it 🤖 Agent this PR
SparkPost's errors list A site that passes the list on keeps its shape, with PostHog's message as its one entry; one that reads other fields of an entry stays 🤖 Agent this PR
Plain-text part An html-only site, inline or from a template, moves only when the person finds a text/plain part in one email SparkPost sent; SparkPost's docs don't say whether it made one, and PostHog always does. Otherwise it stays with turns the plain-text part off or plain-text part not checked 🤖 Agent this PR
SDK facts SparkPost's docs don't give Ruby send_message's fields and PHP's error answers come from the client installed in the app, named in the plan row; the site stays when it doesn't settle them. SDK source past Fleet's Git allowlist was not read 🤖 Agent this PR
Unsubscribe header A send SparkPost gave its List-Unsubscribe header moves only when the person agrees it may go without one; PostHog's send takes no headers 🤖 Agent this PR
Raw HTTP deadlines Follow the core's Step 7: the mapping only names which SparkPost-era options are deadlines, and the stage limits Step 7 doesn't list. A library's default is not the site's own deadline, and axios's and ky's timeout and Ruby's whole-request limits and partial Net::HTTP timeouts are limits the SDK can't keep, as the core now says. Replaced the folder's own stricter stage-limit rule once the core added one 🤖 Agent this PR
Webhook events with no PostHog event A handler on such an event, such as delay, list_unsubscribe or policy_rejection, keeps its site, as the core's Resend mapping does 🤖 Agent this PR
Re-importing suppressions Follows the core's Step 5 "Re-import only" and the report's weekly re-import while any site stays on SparkPost; account.md only says where SparkPost's list comes from, so the folder needed no change 👤 Human #472
SparkPost SMTP Stays: PostHog has no SMTP relay. Dropped the referral to migrate-email-smtp, which this release doesn't ship 🤖 Agent this PR
Proof kinds Live run and prod-shape, recorded as gaps: the inputs are customer-written code and each person's SparkPost account, and the planned trial on generated codebases covers both after the SDKs land. A shadow diff of every bundle, owed at high. 🤖 Agent this PR
Merge risk High, no switch-off: a wrong instruction silently changes an agent's rewrite, the skill ships with the next release, and the live run is uncovered. Two-way door until the release ships it, one-way once agents run it 👤 Human this PR

Involvement: qa-swarm, panel rounds 1 (fresh-eyes, correctness, risk, design, anti-overengineering, sweep, and two team perspectives) and 2 (the same, without sweep), thirty-three final reads, and fourteen PostHog Review rounds. Final reads past the sixth each ran with a recorded reason. Earlier rounds on older heads are in the PR history.

🤖 Agent context

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5, high). Reviews on GPT-6.1 Sol (high), the fresh-eyes lens on Opus 5.5.

🤖 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 29 commits November 5, 2026 10:15
… key) into feat/migrate-email-sendgrid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #472's added assertion in the Step 7 proxy test. No skill text changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send of a template stored at the provider now moves by bringing the
email into the code: its HTML and text become a file next to the send,
the code renders the variables with the provider's escaping, and the
site sends the code form. A template made in PostHog, sent by its ID,
is used only when the person asks for one.

- template-to-code.md: the check, where the email goes, how each
  language renders it without a new dependency, and the escaping rule.
- Steps 3, 7 and 9: the new default, the template form on request, and
  the report items for emails brought into the code.
- SendGrid: Handlebars in the code, including the helpers the template
  form couldn't take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send with its own content and legacy substitutions stayed on SendGrid
only because SendGrid put the values in. The code now does the same
replace before the send, under the escaping rule of template-to-code.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 10 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>
…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 3 commits November 13, 2026 16:27
- account.md maps each SparkPost source to PostHog's reason (bounce,
  complaint, manual), and says only a bounce blocks a moved send.
- mapping.md feeds Step 3's new suppression question: the Manually Added
  and Compliance entries and the app's suppression writes, per account,
  with the reason `relies on SparkPost's suppression list`.
- mapping.md drops the "which entries count" text Step 5 no longer reads.

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 d6f261c: 3 Should fix.

This is review round 7 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Silthus and others added 2 commits November 13, 2026 16:38
…y send_message fields from the installed gem

A site that reads a header of SparkPost's answer, other than the retry
delay, now stays, as Resend's does under #472's Step 7. A Retry-After
read moves to the PostHog error's retry delay. A Ruby send_message
call maps the fields the installed gem sent, where a keyword can
override a positional argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SparkPost sites rethrow

The rethrown Error copied only statusCode, name and code, so a catch
that moved from Retry-After to the retry delay always took its
missing-header path.

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 9459b57: 1 Should fix.

This is review round 8 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

…ed package and adapter

The PHP paragraph claimed every API error throws SparkPostException.
SparkPost's docs name no response or exception class, and whether an
error answer throws can depend on the app's HTTP adapter. The agent now
reads the installed package with the app's adapter, names the files in
the plan row, and moves a response check to a check of PostHog's error.

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 eebfe4e: 1 Should fix.

This is review round 9 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Silthus and others added 2 commits November 13, 2026 16:51
…only site moves

PostHog makes a text part from the HTML whenever a send has none.
SparkPost's docs don't say whether SparkPost did, so an html-only site,
inline or from a template, now moves only when the person finds a
text/plain part in one email SparkPost sent. Otherwise it stays, as the
shared template check does for a provider that sent no text part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…il sent without one

A site whose text can be empty kept only html after the move, dropping
its own text when set. The send now passes text where it isn't empty,
and the person checks an email SparkPost sent without text, since one
that carried the site's text says nothing about a generated part.

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 40c9742: 1 Should fix.

This is review round 10 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

@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 c77373c: 1 Should fix.

This is review round 11 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Silthus and others added 3 commits November 13, 2026 16:57
…pp can change

The tracking, minimization and transactional rules read each account's
settings as they are today. An app that updates the account or a
subaccount through the API could turn open tracking off later, and that
change would no longer reach the moved sends, so such sites stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…site passes them on

A site that returned or looped over SparkPost's errors list got
PostHog's single message as a string, so consumers that iterate it
broke. The message now takes the list's place as a list of one entry of
the same kind, and a site that reads other fields of an entry stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… their own code

An endpoint answer or a message to another service that carried
SparkPost's error objects can have readers of code or description,
which a list of one message drops. The person confirms those readers use
only message, or the site stays.

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 5852c15: no findings.

@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 f8204a9: no findings.

@Silthus
Silthus marked this pull request as draft November 14, 2026 17:47
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