Skip to content

feat(migrate-email): attachments for SMTP - #526

Draft
Silthus wants to merge 52 commits into
feat/migrate-email-smtpfrom
feat/migrate-email-smtp-attachments
Draft

Silthus wants to merge 52 commits into
feat/migrate-email-smtpfrom
feat/migrate-email-smtp-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 13, 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

The SMTP variant of migrate-email (#463) keeps every send that carries a file on SMTP, because PostHog's send didn't take files. The PostHog SDKs now send files from code, and #510 teaches the shared steps how. This PR maps the SMTP clients' file options onto them, so that a send with an invoice, a calendar file or an inline logo can move too. The clients are Nodemailer, Python's email, Ruby mail, PHPMailer, Symfony Mime, gomail, go-mail, MailKit, System.Net.Mail, lettre, and gen_smtp's mimemail. In a separate commit, it also rechecks the SMTP claims that rested on package source instead of the clients' docs.

Important

Stacked on #510 (Resend attachments), which is stacked on #486. The base is feat/migrate-email-smtp (#463), so the diff includes #486 and #510 until its ancestors land and it is retargeted to main. Only references/smtp/ is this PR's change. It also merges #510's head 41e37f1. Merge order: #472, the providers, #486, #510, then this one.

Impact

Moved sends that carry files and arrive with migratedFrom: 'smtp' show whether sites with files now move off SMTP. Read it 14 days after the Wizard runs migrate as a family with this variant.

Warning

🟡 Merge risk: medium · two-way door until the next context-mill release ships the skill; one-way for each app once an agent runs it, because a migration's changes persist in that app's code

  • The diff changes only references/smtp/mapping.md and sdk-reference.md. Until the skill ships, nobody runs it, so no running code changes.
  • Once it ships, a site with files that stayed on SMTP moves when Step 3 "Files" and the new "Attachments" section let it. The first failure path is a moved email whose files arrive differently: a filename or content type the client used to fill in from a path or an extension, or a content ID rewritten for Ruby mail's inline files or for a generated ID. Step 8's check email sends one email with files before cutover, and a field_not_accepted puts those sites back.
  • The second failure path is new code in the moved site. A file the SMTP client read from a local path or a stream is now read by the moved code, where the client read or checked it, in the same try as that call. A client whose read point its docs don't give is read from the installed client, or keeps the site. A missing file or an oversized stream would fail there, not in the SMTP client. Step 8's check email doesn't exercise a missing file.
  • Uncovered: no agent run of the SMTP variant with files, and no real send through PostHog of an inline image from any SMTP client. feat(migrate-email): move a Resend send's attachments to posthog-node #510's real send covered a file the email carries, from Resend.
  • PHPMailer sites, with or without files, now move only when their PHPMailer is built with a literal setting in the send's function, its class's constructor, or an app helper, and is the only one that can reach the send. A site whose setting is a run-time value, or whose PHPMailer comes from a parameter, a container or a subclass, stays as PHPMailer's exceptions setting is unknown, where feat(skills): move SMTP sends to PostHog #463 moved it. A PHPMailer whose calls across the class can't be told keeps the site as a PHPMailer built across calls, and sites that share one PHPMailer move or stay together (shares its PHPMailer with <site>: <its reason>).
  • Switch-off: revert this PR, which keeps every site with files on SMTP again, as feat(skills): move SMTP sends to PostHog #463 does.
  • Tier: non-critical

Rollout

Changes

  • smtp/mapping.md, "Fields": the attachments row now maps to PostHog's attachments and points to Step 3 "Files" and the new section. The icalEvent row says an .ics file in attachments is a file like any other, with its content type's parameters checked as Step 3 says. disableFileAccess keeps a site whose file comes from a path, as it already did for text and html.
  • smtp/mapping.md, new "Attachments" section. Each part of a file, and how it maps:
    • Filename. A name the code sets moves as it is, in each client's spelling. A file read from a path with no name gets the client's own name: gomail's base name, as its Rename option says, and for the other clients whatever the installed client gives. A file with neither a name nor a path keeps the site, as Step 3 says. This covers Nodemailer's filename: false, Nodemailer content with no filename, and lettre's new_inline.

    • Content. Each byte source maps as follows:

      • Bytes move as they are.
      • Strings the client decodes, such as Nodemailer's encoding or Ruby mail's encoding:, are decoded the same way.
      • Text moves as its bytes in the client's charset. Python's add_attachment with a str keeps the site (a text file Python's email package rewrites), since Python changes its line endings and the bytes depend on the policy.
      • A local path is read in the moved code, since the client read it inside the app, not the provider. The read happens where the client read or checked the file: at the add for MailKit and Ruby add_file, at the send for Nodemailer, at the add for PHPMailer, which copies what the installed PHPMailer does there (answer false, or throw its own class with its message and code), and elsewhere where the installed client reads it. A PHPMailer site that throws keeps its old catch for that throw beside a new PostHog\EmailError catch. Since the moved send sets no ErrorInfo, a variable stands in for it: empty where the site starts building the message, set where a file check refuses and in a PostHog\EmailError catch right around the send, before any finally or outer catch, which then rethrows with exceptions on, or answers false or goes on with exceptions off, as the old send did, with the site moved only when the PHPMailer it sends with is built as new PHPMailer(), (true) or (false) in the send's function, its class's constructor or an app helper, and is the only one that can reach the send, and kept for a run-time value, a parameter, a container or a subclass (PHPMailer's exceptions setting is unknown). Calls across the class that can't be told keep it too (a PHPMailer built across calls), and sites that share a PHPMailer move or stay together, a site with no reason of its own naming its partner (shares its PHPMailer with <site>: <that site's own reason>). The variable is never set in a catch that also takes other errors, and read wherever $mail->ErrorInfo was read, callers included. A branch on getCode(), a call of isError(), or a read the variable can't reach, keeps the site (reads PHPMailer's error). This tightens feat(skills): move SMTP sends to PostHog #463's PHP Rewrite bullet, which this PR already rewrites. System.Net.Mail's Attachment(path) keeps the site, since its docs give no read point or name.
      • A URL or Nodemailer's href keeps the site (the provider fetches <file>).
      • A data: URI moves as its decoded bytes.
      • Streams and readers are read to the end, with a Nodemailer stream's text chunks as UTF-8 bytes. An unbounded stream keeps the site (files can pass 40 MB).
      • gomail's SetCopyFunc is called with an in-memory buffer.
    • Content type. A type the code sets moves as it is, parameters included. A type the client picks from an extension is set the same way and noted in the plan row: the filename's, or for PHPMailer the path's, not the display name's. MIMEImage's guess from the image's bytes keeps the site (a content type the client guesses).

    • Content ID and disposition. PostHog shows a file with a content ID inline, as the SDK's own docstring says. The content ID moves as each client gives it, in these forms:

      • the bare ID without <>;
      • Symfony's cid:<name>, which keeps the name;
      • gomail's Embed, which gives the base name;
      • a fixed short domain for make_msgid() and GenerateMessageId(), so the ID keeps the 78-character limit;
      • a fixed ID for Ruby mail's attachments.inline, where the code read url.

      A disposition PostHog doesn't give the file keeps the site (a file's disposition PostHog doesn't set). That covers an inline file with no content ID, an attachment with a content ID, Nodemailer's cid on a non-image, and Nodemailer's message/*.

    • What keeps a site. These keep the site, each with its reason:

      • Nodemailer's raw, and parts written by hand.
      • Headers on a file (the headers row).
      • httpHeaders.
      • Any way of adding a file that the section doesn't name (a file this mapping doesn't name).

      A transfer encoding moves without it.

    • Limits. The SMTP server's own size limit, and Nodemailer's 50 MB data: URI limit, appear next to PostHog's 10 files and 40 MB.

    • Templates. SMTP stores no templates. email-templates' config attachments map like the site's.

    • Where the docs say nothing. The agent reads the installed client, names the file in the plan row, and the case joins the live-run fixtures below. The .NET clients ship no source, so a file whose name or type they would fill in keeps the site (a file part the client fills in).

  • smtp/mapping.md, "Rewrite": each file becomes one attachments entry. email-templates' merge of the config's attachments is read from the installed package.
  • Separate commit 82cdc20, which rechecks claims that rested on package source against the clients' docs:
    • go-mail: DialAndSendWithContext's context now cites the docs ("controls the connection's timeout and cancellation"; Send takes no context). The already-done context rule follows from that. WithTimeout's per-message detail is dropped, and the site stays either way.
    • aiosmtplib: the detail that timeout limits a whole reply is dropped, and the site stays either way.
    • System.Net.Mail: Timeout counts on a synchronous Send only, now cited to its docs.
    • email-templates: how send combines config and site attachments is read from the installed package. The earlier claims ("never merges the two lists", and later "no attachments to merge") are gone.
    • Go clients: "never panics on an SMTP refusal" is dropped. They answer an error, as their docs show.

How did you test this code?

  • Lane gate on 2497e64: vitest run scripts passes 344 tests in 30 files, node scripts/build.js exits 0, and the tree is clean.
  • Sources, all the clients' own docs:
  • The clients' source wasn't read. Fleet's Git allowlist blocks every SMTP client's GitHub org: nodemailer, python, PHPMailer, symfony, go-gomail, wneessen, jstedfast, dotnet, lettre, gen-smtp, Vagabond, mikel, ruby, cole, swoosh, and forwardemail for email-templates. No package from a registry is cited. Claims only source would back were dropped, or became a rule to read the installed client.
  • Live-run fixtures, the cases where the docs say nothing and the agent reads the installed client:
    • A filename for a file read from a path without a name: Nodemailer path, Ruby add_file, PHPMailer addAttachment, Symfony new File($path), and go-mail AttachFile.
    • A content type for an uncommon or missing extension, in each client.
    • go-mail's default Content-ID for EmbedFile and EmbedReader.
    • What each client does with a file it can't read, and where PHPMailer, Symfony, gomail and go-mail read a file from a path.
    • Ruby mail's attachments.inline, with a fixed content ID in place of url.
    • email-templates' merge of config and site attachments.
  • Follow-up for the core: 7-rewrite.md and 8-verify.md put a site back without pointing to the provider's rule for sites that share one client. This lane doesn't edit the shared steps, so mapping.md states the rule for PHPMailer.
  • Owed proof: a live run of the variant with files, after the ancestors land. The gate and ReviewHog run after the retarget.

Decisions

Question Answer Who Where
Attachments for every provider A follow-up PR per provider off its provider PR, merged after #510 👤 Human this PR
SMTP's "provider" for files Nodemailer and the other SMTP clients' attachment options 👤 Human this PR
Sources when Fleet blocks a client's org Docs only, never registries, mirrors or repo-name loops; read the installed client where the docs say nothing 👤 Human this PR
A file the client read from a local path Moves, read in the moved code in the same try: the client read it inside the app, so no provider fetched it under Step 3 🤖 Agent this PR
Disposition PostHog shows a file with a content ID inline, so any other pairing keeps the site 🤖 Agent this PR
.NET defaults the docs don't give Keep the site, since the installed .NET clients ship no source to read 🤖 Agent this PR
Registry-backed claims in references/smtp/ Rechecked in their own commit against the docs; detail that changed no outcome was dropped 🤖 Agent this PR

Involvement: one agent wrote the mapping from the clients' docs. Cross-family review (GPT-6.1 Sol on correctness, Opus 5.5 on fresh eyes) with fleet proof review --scope fixes from the merge of #510, 3c2558c: round 1 on 82cdc20 raised nine findings, all fixed in 7dabc42 (where each client reads a file, stream chunks, Python text, PHPMailer's type source, MIMEImage, inline .NET content IDs, MIMEBase headers, email-templates' list merge). Round 2 on 7dabc42 raised six, fixed in 06d6139 and this description (Python text keeps the site, PHPMailer's read point, System.Net.Mail's Attachment(path), Nodemailer stream chunks, MIME-Version). Round 3 on 06d6139 found that PHPMailer with exceptions on throws at the add, fixed in 1f48221. Round 4 on 1f48221 found that a combined catch ran PostHog-only methods on PHPMailer's exception and that older PHPMailer versions answer or throw differently, fixed in 8a9663f. Round 5 on 8a9663f found that code reading $mail->ErrorInfo lost the refusal's message, fixed in 6a2566a. Round 6 on 6a2566a found errorMessage() and broader catches that read ErrorInfo, fixed in 8eb51e2. Round 7 on 8eb51e2 found callers with exceptions off, double escaping, and getCode() branches, fixed in f47a17b by one rule for every read of the send's error. Round 8 on f47a17b found that a broader catch would show other errors' messages, fixed in 4c09a4b. Round 9 on 4c09a4b found that a rethrown failure or a file refusal in a broader catch lost its message, fixed in 9c976c9 by one variable that stands in for ErrorInfo. Round 10 on 9c976c9 found where that variable is set and reset, and errorMessage() in a broader catch, fixed in c98b7ac. Round 11 on c98b7ac found that the new catch's rethrow broke a site whose send answered false, fixed in 2887c03. Round 12 on 2887c03 found a finally that reads ErrorInfo before the outer catch, and isError(), fixed in 4139695: the message is always captured right around the send. Round 13 on 4139695 found that rethrow and false could both apply with exceptions on, and that isError() resets per send, fixed in 7d9ea49: the answer follows the exceptions setting, and isError() keeps the site. Round 14 on 7d9ea49 found an exceptions setting read at run time, fixed in 5534cd3: the catch branches on the same value, and the site stays when its source can't be read. Round 15 on 5534cd3 found that the add-time file check needed the same branch, fixed in d025ca6, with the value kept in one variable and the stay given its own reason, PHPMailer's exceptions setting is unknown. Round 16 on d025ca6 found that the stay should follow where the PHPMailer is built, not whether the value's source is readable, fixed in 4a29771. Round 17 on 4a29771 found a PHPMailer from a DI container or vendor factory, fixed in a1aa719: that site stays too. Round 18 on a1aa719 found that stay hung on a run-time value, fixed in ca9eb65: a PHPMailer from elsewhere moves only when its builder uses a constant setting the agent can read. Round 19 on ca9eb65 found callers that build it with different settings, fixed in 723857f: every builder must use the same one, and new PHPMailer() counts as off. Round 20 on 723857f found a site whose own PHPMailer is only a fallback for one a caller passes, and a subclass built with no argument. Fixed in 11645c9 with one simpler rule, as the lens put forward: the site moves only when it builds its own PHPMailer with a literal setting, and every other source keeps it. Round 21 on 11645c9 found that a wrapper class's constructor or a helper that builds it with a literal counted as elsewhere, fixed in aa81bba: either counts as the site's own when it is the only PHPMailer that can reach the send, and the merge-risk callout names the PHPMailer sites that now stay. Round 22 on aa81bba found that a property-held PHPMailer carries what the constructor and setters set, fixed in c18a389: the message is every call on it that can reach the send, and the site stays when those can't be told. Round 23 on c18a389 found that a site that stays could lose a shared constructor's calls, fixed in 993fc72: they stay as they are while a site that stays shares the PHPMailer. Round 24 on 993fc72 found a shared ErrorInfo read and a doubled failure branch, fixed in b3ba006 with one rule: a PHPMailer that a site that stays also sends through keeps the moving site too. Round 25 on b3ba006 found that Step 8 could put back one of two sites sharing a PHPMailer, fixed in a21bc38: they go back together, and the plan row names the shared site. Round 26 on a21bc38 found that Step 7 can undo a site too, fixed in 94a123f: the rule covers both steps, and the partner's row names the shared site and its reason. Round 27 on 94a123f found that the partner's reason depended on when the stay was found, fixed in e01b296: it always reads shares its PHPMailer with <site>: <that site's reason>. Round 28 on e01b296 found two sharing sites that each stay for their own reason, fixed in 2497e64: each keeps its own reason, and only a site that would otherwise move names its partners.

🤖 Generated with Claude Code

Silthus and others added 24 commits November 9, 2026 12:29
…opens_and_clicks

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

PostHog never tracks clicks on a code send, so click tracking that is off needs nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace track_opens_and_clicks with tracking in the rewrite step and
every SDK's rewrite file. A provider's open tracking that is off turns
off the opens switch only, so engagement events stay on. The skill
never turns on click tracking, because a link can carry a token, and
it notes sites whose provider tracked clicks for the report.

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

Use the Go, Rust and .NET names for tracking. A site that moves to a
template made in PostHog sends no tracking, because the template uses
its own setting, so the report asks the person to check it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o sensitive

A code send takes an optional sensitive flag, and its clicks follow it
unless tracking sets them. The skill marks a send sensitive when the old
call turned off click tracking for that email or marked it sensitive. It
never marks a send as not sensitive, and drops the claim that clicks are
always off by default for code sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send that leaves sensitive out takes the setting of a PostHog
template with the same key. The skill can't see the project's templates
or tell whether a link carries a token, so a key that matched a template
without protection would start rewriting a reset link. Pin the flag on
every code send, and say so in the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ack-opens-and-clicks

Keeps the base's error-handling rules, setup and write-down additions,
and Elixir's named-instance send, with this branch's tracking and
sensitive rules on top. Step 7 now turns opens off from the plan note
instead of waiting for the person's yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A site whose emails had no open pixel, or whose provider's open tracking
nobody can confirm, now moves with the opens switch off instead of
staying or waiting for the person's yes. The plan notes it as opens off
with its reason, and the report lists those sites, the clicks PostHog no
longer counts, and that every code send is marked sensitive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resend's attachments map to the send's attachments in posthog-node:
filename, content, content type and inline content ID. A file with a
path, no filename, a refused extension, or more than 10 files keeps the
site on Resend, as do sites outside JavaScript and TypeScript. Step 8
sends an email with files when a moved site has them, and puts those
sites back when PostHog doesn't take files from the project yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 3 decides when a site with files stays, whatever the provider, and
lists every extension PostHog refuses. Resend's mapping keeps only its
own file fields. The report says what would let a staying site move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd-clicks' into feat/migrate-email-resend-attachments

# Conflicts:
#	context/skills/migrate-email/references/9-report.md
#	context/skills/migrate-email/references/resend/mapping.md
Step 7 says what every SDK's code send does with files: the 10-file and
40 MB limits, the upload before the send, and its errors. Each rewrite
file names its SDK's field and the form its content takes, since some
take only bytes and some read any text string as Base64. The bundled
api.md may still call attachments unavailable, so the steps win for
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run of migrate-email-resend on an app with a calendar invite showed
where the steps fell short. They now cover:

- preflight when the MCP sees another project, or the app already
  sends code emails
- a PostHog key under the app's own name, or in the hosting platform's
  environment
- code and docs around the sends: provider-key switches, counts and
  limits, promises in legal docs and specs, and the app's own PostHog
  sends, whose engagement setting moved sends follow
- a send moved into a runtime the SDK runs in, such as a Convex
  "use node" action
- a deadline that now limits each request of a send with files
- an idempotency key built from a hash of the request body
- tests of sends with files: the upload requests, and hashing under a
  faked clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- preflight: an app that already sends code emails wins over an MCP
  that can't see the project; suppressions never import into another
  project
- Step 3 reads the rewrite file's send for the files check, and counts
  the release Step 7 would install
- the email client splits a shared config function, and goes in the
  file of the runtime the send runs in
- one template key for a site that sends several kinds of email
- a used-up quota that the old code retried as a 429 is noted
- tests: answer at once under a faked clock, answer the app's own
  PostHog capture, assert on the added fields, and keep the Node digest
  stub in a setup file outside Convex's folder
- the report's notes placeholder closes where it should

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pp's own distinct ID helper

Also reads Step 5's MCP check from what Step 2 found, not what it recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a legal text

Run 4 worked around both by hand within Step 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce PostHog takes them

The real send on prod-eu refused `text/calendar; charset=utf-8;
method=REQUEST` with `not_mime_type`: PostHog takes a bare type only.
Taking `method` out makes a mail app show a calendar invite as a file.
Step 3 marks such a site, Step 8's check email decides, and a refusal
puts the site back on the provider rather than stripping the
parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt ID in Step 3

A filename takes at most 255 characters and no path separator or control
character (unsafe_filename), a content type at most 78 characters, and a
content ID at most 78 characters and no angle bracket or control character
(unsafe_content_id), as code_send_fields.py on PostHog's master refuses them.
A site whose value breaks one stays on its provider with a reason, so each
provider's attachments follow-up points here instead of restating them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… content cell

Run 5 (Steps 1 to 3 on a fixture with three Resend sends with files) held
a user-built filename and passed a fixed one, as the header rules mean, but
also held `contentId: "<logo>"`, which Resend's mapping already turns into
the bare `logo` the HTML shows. The rules now hold for the value the send
passes after the mapping. The re-run moved that site and still held the
user-built filename. Each file of a moving site now goes in the plan row's
content cell, where both runs had to guess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-email-smtp-attachments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Map each SMTP client's file options (Nodemailer, Python email, Ruby mail,
PHPMailer, Symfony Mime, gomail, go-mail, MailKit, System.Net.Mail,
lettre, gen_smtp's mimemail parts) onto PostHog's filename, content,
content type and content ID, from the clients' own docs. Files read from
a local path move as their bytes; URLs, raw parts, per-file headers and
dispositions PostHog doesn't set keep the site. Parts the docs don't
describe are read from the installed client.

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

Each claim now cites the client's docs or tells the agent to read the
installed client:
- go-mail: DialAndSendWithContext's context controls the connection, as
  its docs say; WithTimeout's per-message detail is dropped (the site
  stays either way).
- aiosmtplib: the timeout's reply-versus-read detail is dropped (the
  site stays either way).
- System.Net.Mail: Timeout applies to synchronous Send, as its docs say.
- email-templates: how send combines the config's attachments with the
  site's is read from the installed package.
- Go clients: answer an error on a failure, as their docs show; the
  "never panics" claim is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

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/go-sdk
  • /wizard-ci mcp-analytics/python-sdk
  • /wizard-ci mcp-analytics/ruby-sdk
  • /wizard-ci mcp-analytics/rust-sdk
  • /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 5 commits November 13, 2026 16:49
…ed types

Review round 1 on the SMTP attachments mapping:
- Read a file from a path where the client read or checked it (at the
  add for MailKit, PHPMailer and Ruby add_file; at the send for
  Nodemailer), and keep the bytes to the send.
- Turn string chunks of a stream into bytes before concatenating.
- Build Python text parts the same way and pass their decoded payload
  and charset parameter.
- PHPMailer takes the type from the path, not the display name;
  MIMEImage's guessed type keeps the site.
- System.Net.Mail's ContentId counts only on an inline Attachment;
  MIMEBase headers that map are named; email-templates keeps
  attachments out of defaultsDeep.
- Drop the claim that Ruby mail sets its own inline Content-ID, which
  no docs back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2 on the SMTP attachments mapping:
- Python add_attachment with a str keeps the site: Python rewrites the
  text, and the bytes depend on the policy and transfer encoding.
- PHPMailer is checked at the add and read where the installed client
  reads it; System.Net.Mail's Attachment(path) keeps the site, since
  its docs give no read point or name; MailKit's LinkedResources.Add
  is named beside Attachments.Add.
- Nodemailer stream text chunks become UTF-8 bytes, as Node's write
  takes them.
- The MIME-Version header Python's MIME classes add is allowed.

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

With new PHPMailer(true), addAttachment and addEmbeddedImage throw at
the add for a file they can't read. The moved check now throws the same
class there, the catch keeps it beside PostHog\EmailError, and the check
copies the installed PHPMailer's own test rather than is_readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At the add, the moved check now does what the installed PHPMailer's
method does: answer false, or throw its own class with its message and
code. The site keeps its old catch of that class for the throw and adds
a PostHog\EmailError catch for the send, so no PostHog-only accessor
runs on PHPMailer's exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHPMailer put a refused file's message in $mail->ErrorInfo, which the
moved check doesn't set. Code that reads it after the add, or in either
catch, now reads $e->getMessage() or the message the check kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 23 commits November 13, 2026 17:18
…send's error reaches

With exceptions on, $mail->ErrorInfo and $e->errorMessage() in a
PostHog\EmailError catch, a broader catch or a caller now become
$e->getMessage(), since the moved send sets neither. The kept catch for
a refused file keeps errorMessage().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With exceptions on or off, every read of the send's error that the
failure reaches, callers included, takes $e->getMessage(): ErrorInfo
keeps the code's own escaping, errorMessage() gets htmlspecialchars,
base \Exception methods carry over, and a branch on getCode() or a read
the moved code can't reach keeps the site as `reads PHPMailer's error`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or its error

A broader catch also takes other errors, so turning its ErrorInfo read
into getMessage() could show a database or path message. It now takes
the message only for a PostHog\EmailError. errorMessage() becomes the
HTML the installed PHPMailer builds around the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The class checks and per-catch rules lost the message when a failure
was rethrown, reached a broader catch, or came from the file check. A
variable now stands in for $mail->ErrorInfo: empty before the try, set
where the send fails and where a file check refuses, and read wherever
ErrorInfo was read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The variable is reset where the site starts building the message, set
by a PostHog\EmailError catch right around the send that rethrows when
the site had none, and never in a catch that also takes other errors.
errorMessage() converts wherever the PostHog error reaches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The variable is set in the site's own catch of the send's error. Where
there is none, a new catch right around the send sets it and then does
what the old send did: rethrow, or answer false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stand-in variable is now always set in a catch right around the
send, before any finally or outer catch runs, which then rethrows,
answers false, or goes on as the old send did. isError() reads the
variable too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns setting

The catch around the send rethrows with exceptions on, and answers
false or goes on with exceptions off; the older false-branch sentence
is gone. isError() keeps the site, since PHPMailer keeps its count its
own way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…run time

A site that builds PHPMailer with a run-time exceptions value now gets a
catch that branches on the same value, and stays when the agent can't
read where that value comes from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eptions value too

The run-time exceptions value is kept in one variable where PHPMailer is
built, and both the add-time file check and the catch around the send
branch on it. A site whose value can't be read stays with its own
reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re it is built

A site that builds its PHPMailer keeps the run-time exceptions value in
a variable there, whatever its source. One built elsewhere, such as in a
shared helper, leaves the value out of the site's reach, so the site
stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PHPMailer from a DI container or a vendor factory has an exceptions
setting the agent can't see, so such a site stays as `PHPMailer's
exceptions setting is unknown` rather than assuming the default.

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

A PHPMailer from outside the site moves only when that code builds it
with a constant the agent can read, so a container or factory whose
setting can't be confirmed keeps the site whatever kind of value it
uses.

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

A shared send function whose callers build the PHPMailer with
different settings can't branch on the protected setting, so it stays.
A constructor call with no argument counts as exceptions off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with a literal

Each round found another way a PHPMailer reaches a site from
elsewhere. One rule ends that: the site moves only when it builds the
PHPMailer it sends with as `new PHPMailer()`, `(true)` or `(false)`.
A run-time value, a parameter, a helper, a container or a subclass
keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the site's own

A wrapper class that builds the PHPMailer in its constructor, or an
app helper that returns one, sets a literal the agent can read, so the
site moves as long as no other PHPMailer can reach the send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PHPMailer kept on a property carries what the constructor, other
methods and earlier sends set on it, so the message is every call that
can reach the send. When those calls can't be told, the site stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A site that stays may send through the same PHPMailer, so the calls
outside the moved send stay as they are. A call the moved site made
that can reach a staying site keeps it, and the rule covers a helper
the PHPMailer is passed to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sites that share one PHPMailer share its calls, its files and its
ErrorInfo, so moving one of them can't keep the other's message and
errors whole. The moving site stays too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 8 puts sites back one at a time, which could split two sites
that share a PHPMailer. They now go back together, and the plan row
names the site that keeps another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 7 can undo a site too, so the rule covers both steps, and the
partner's row names the site it shares with and that site's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A partner of a staying site got a different reason depending on when
the stay was found. It now always reads `shares its PHPMailer with
<site>: <that site's reason>`, and `a PHPMailer built across calls`
is kept for calls that can't be traced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When two sites on one PHPMailer each stay for their own reason, each
keeps it. Only a site that would otherwise move names its staying
partners and their reasons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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