Repository navigation
Conversation
Adds the smtp variant of migrate-email: Nodemailer and the SMTP clients of each language with a PostHog SDK, the hosts that name the provider behind them, and how DNS and suppressions follow that provider. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- headers, list headers and Message-ID headers always stay: no PostHog SDK takes headers yet. - A from name with special characters is quoted. - Go and Elixir decode a base64 attachment; Node.js and Python keep it. - A catch of one SMTP error class keeps the site; a broad catch stays broad, and except OSError keeps catching OS errors. - No claims about how PostHog signs or builds the send. Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts: # context/skills/migrate-email/config.yaml # scripts/lib/tests/migrate-email-skill.test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Invalid-send branches (EENVELOPE, EMESSAGE, SMTPRecipientsRefused, SMTPFatalError, MailKit *NotAccepted, 5xx replies) map to PostHog's validation_error name, never a status; Node's thrown error carries name. - Name the region a host or Nodemailer SES service sets, and read records and suppressions in that region of the provider's dashboard. - Say which SMTP packages serve more than these sends. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Test against a wizard branch:
Add Results will be posted here when complete. |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Node: never copy PostHog's HTTP status into responseCode; check callers the error reaches for branches on SMTP error kinds. - Python: SMTPException covers a dropped connection, not a refused one; note the network_error change. MailKit SmtpCommandException rethrows network_error. - Attachments: decode hex and text strings, strip Content-ID brackets. - A message with no To stays; a send-time Date and an unread Message-ID move without the header. - Handlebars plugin skips a message with html; email-templates merges locals and message defaults as send() does. - Keep a local-catcher branch picked by environment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ate smtp Each provider's SMTP row said PostHog has no SMTP relay. With the SMTP variant in this layer, those sends stay for that provider's run and move with wizard migrate smtp. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A check that skips the send when the SMTP settings are missing moves, and checks the PostHog secret key variable instead. - A replyTo passed in as a parameter is read at its callers, and moves when every caller passes one bare address or none. Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts: # context/skills/migrate-email/references/postmark/mapping.md
# Conflicts: # context/skills/migrate-email/references/loops/mapping.md
…me the skill fallback for SMTP smtp.loops.so carries the JSON of a Loops template send, so the SMTP skill keeps such a site and points to wizard migrate loops. Each provider's SMTP row also names wizard skill migrate-email-smtp for a wizard whose migrate takes no provider yet. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…dd lodash when the app lacks it send takes the site's attachments or else the config's, and never deep-merges the two lists. pnpm doesn't resolve lodash from the app when only email-templates depends on it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ding its attachments Co-Authored-By: Claude Opus 5.5 <[email protected]>
…s host had Co-Authored-By: Claude Opus 5.5 <[email protected]>
… than the message id stays Co-Authored-By: Claude Opus 5.5 <[email protected]>
…t connection as network_error Co-Authored-By: Claude Opus 5.5 <[email protected]>
…used and a lost connection went to the same place Co-Authored-By: Claude Opus 5.5 <[email protected]>
…used and a lost connection ran the same code Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…l-smtp 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]> # Conflicts: # context/skills/migrate-email/references/resend/mapping.md
Point every posthog-sdks.md reference at the rewrite file for the site's language. cc, bcc, envelope-only recipients, and attachments now stay on SMTP, because no PostHog SDK takes them. Drop the attachment conversion bullets, the Attachments by path section, and cc/bcc from the call and field bullets. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
…/migrate-email-smtp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A person who sends over SMTP and asks to move those sends to PostHog gets no help:
migrate-emailcovers Resend and, with #459 and the PRs stacked on it, SendGrid, Postmark, Mailgun and Loops. Each of those keeps its own SMTP sends with "PostHog has no SMTP relay".With this PR,
migrate-email-smtpmoves sends through an SMTP client, such as Nodemailer,smtplibor PHPMailer, to PostHog's email send in each language where PostHog has an SDK with an email send, andwizard migrate smtpruns it. The other providers' runs now point their SMTP sends here.Important
Stacked on #462. Merge that first. This PR's own changes:
b02e1b0...3e93eac.It adds one provider folder, one config variant, one
AGENTS.mdrow and the provider in the bundle test, and changes the SMTP row of four other providers'mapping.mdto point here. 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: 'smtp'show whether people use this skill to move off SMTP. Read it 14 days after the Wizard runsmigrateas a family.Merge risk
Warning
🟡 Merge risk: medium · two-way door
wizard migrate smtp, so reach is few. Then the skill edits their send code in up to eight languages, and a wrong rewrite harms silently once they deploy: a refused connection handled as a lost one, an error branch widened, or tracking turned on where the host had it off. Each moved site keeps its error behavior and its host's tracking, a site stays whenever a rule can't be checked (the host's tracking, Python's connection errors, a read of Nodemailer'sinfo), and the report's "Before cutover" list names what the person checks first.references/smtp/, only the reason text of one row changes in Resend's, SendGrid's, Postmark's and Mailgun'smapping.md: their SMTP sends still stay, and the report now nameswizard migrate smtp. No shared step and no build code changes.git revertthe merge commit. It removes the SMTP variant and the four pointers.Summary
references/smtp/holds the three files each variant has:sdk-reference.md: the SMTP clients in Node.js (Nodemailer), Python (smtplib,aiosmtplib), Ruby (mail,net-smtp), PHP (PHPMailer, Symfony Mailer), Go (net/smtp, gomail, go-mail), .NET (MailKit,System.Net.Mail), Elixir (gen_smtp) and Rust (lettre), the framework mailers, and the two Node.js renderers that run inside the send. A host table names the provider behind each host, and the host or a Nodemailerservicename sets that provider's region.smtp.loops.socarries a Loops template send as JSON, so its sites stay with the reasonLoops SMTP send: run wizard migrate loops. Nodemailer's non-SMTP transports,smtp-serverandmailparserare listed as not SMTP.mapping.md:list),icalEvent,priority,sender,dsn, AMP and other alternatives, a message the code writes by hand (raw,net/smtp,Net::SMTP), several recipients, a message with noTo, framework mailers, and a language without a PostHog SDK that sends email. Sites withcc,bcc, envelope-only recipients or attachments stay too, because no PostHog SDK takes them yet. Adateof the send time and amessageIdnothing reads move without it. AreplyTopassed in as a parameter is read at its callers.captureEngagement: false, and one of each, or<host> tracking not checked, stays.EENVELOPE,SMTPRecipientsRefused,Net::SMTPFatalError, MailKit'sRecipientNotAccepted, a5xxreply) readsvalidation_error. A branch on a failed login, a connection error or a temporary4xxrefusal keeps the site, in the site or in a caller its error reaches. A site that reads Nodemailer'sinfobeyondmessageId, such asinfo.envelope, stays. A check that skips the send when the SMTP settings are missing checks the PostHog secret key instead, with the same log line. A local catcher the code picks by environment, such as Mailpit or ajsonTransport, keeps its branch.name,codeandstatusCode, neverresponseCode, which SMTP code reads as a reply where4xxis temporary. A Pythonexcept smtplib.SMTPExceptionmoves only when a refused and a lost connection ran the same code before: a handler that is a bareraiseand nothing else, or anexcept OSErroron the sametrythat runs the same code. Otherwise it stays, with the reasonPostHog can't tell a refused connection from a lost one. Ruby'sNet::SMTPgroup rescue and MailKit'sSmtpCommandExceptionraisenetwork_erroragain first, since neither covered a failed connection;System.Net.Mail'sSmtpExceptiondid, so it needs no check.nodemailer-express-handlebarsrenders the view with the plugin's own options first, and sends the message's ownhtmlwhen it has one.email-templatesmoves only withsend: trueandpreview: false, mergesviews.localsand the config'smessageassenddoes, in onedefaultsDeep, since a moved site has no attachments to pick between, and addslodashwhen the app lacks it. A header that names a template stored at the provider keeps its site.$workflows_email_*event. A handler that matches by the Message-ID, the provider's message id or a header keeps its sites.mailgem under Action Mailer, and MailKit's IMAP.account.md: the provider behind the host holds the DNS records and any suppression list, read in the region the host names. Its records sit next to PostHog's, and a mailbox provider's MX, SPF and DKIM records stay. No provider key is created. A sending service's dashboard export maps to bounce, complaint and manual entries. Mailbox providers and a server the person runs keep no list, and soft bounces and unsubscribes are left out, with the reason why.Outside the folder, Resend's, SendGrid's, Postmark's and Mailgun's SMTP row now reads "Always here", and names
wizard migrate smtp, orwizard skill migrate-email-smtpon a Wizard whosemigratetakes no provider yet.The mapping names PostHog's fields, client and errors through the rewrite file for the site's language. Those files come from #472, which the stack now sits on under #459: one
references/rewrite-<language>.mdper PostHog SDK (Node.js, Python, Go, Ruby, PHP, .NET, Rust and Elixir), and Step 7's single list of what every SDK's send takes. Steps 7 and 8 run each rewrite file's check on the whole project, in place of a typecheck per language. #459 deletesposthog-sdks.md, and the plan'slangcolumn is nowlanguage.Evidence
Lane gate on
3e93eac:bunx pnpm@10 testpasses 257 tests in 29 files.CI: green, 7 checks.
Proof gate:
fleet proof gateat3e93eac:gate: pass (kinds: live-run, prod-shape; review: carried from 0d981d6; 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
0d981d6: GPT-6.1 Sol read this round's change,a810b96and the forward merges below it. Two findings, each answered:smtp/mapping.mdline 20: cc, bcc and attachments now always stay, which the reviewer read as a loss of the installed-capability checks, since the Node.js, Python, Go and Elixir SDK sources take these fields. Rejected: Step 7's list, from feat(migrate-email): move Resend sends in every PostHog SDK's language #472 atbb711c5("What every SDK's send takes and answers"), says no PostHog SDK takescc,bcc, attachments, headers, tags, a send time or severaltoaddresses yet, and feat(migrate-email): move Resend sends in every PostHog SDK's language #472's owner checked that against each SDK head. A mapping that moved them would promise a send the SDK can't make, and a site that stays keeps sending as before. When an SDK release adds these fields, feat(migrate-email): move Resend sends in every PostHog SDK's language #472's list changes and the provider rows follow.migrate-email-skill.test.jsline 157: no behavioral test covers the Resend rewrites in the other languages; the test checks only that two phrases are gone. Rejected for this PR: the tests are feat(migrate-email): move Resend sends in every PostHog SDK's language #472's (4b0f257), and send tests against each SDK can't run before the SDK releases with the code-form send. That proof is the planned next step, and the PR stays a draft until then. Until then, the dry runs at this head drive the built skill through a fixture app for every provider.Test seams (
migrate-email-skill.test.js): the SMTP bundle resolves every reference, holds its three files and the eight rewrite files, noposthog-sdks.md, and no other provider's folder; itsmapping.mdnamesmigratedFrom: 'smtp'; it is amigratesubcommand.Reused at
3e93eac. 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 at0d981d6stand. The lane gate above ran at this head.Dry run at
0d981d6(live-run proof).0d981d6addsa810b96, which points the SMTP mapping at the rewrite file for the site's language in place ofposthog-sdks.md(7 references). It turns theccandbcc, envelope-only recipient andattachmentsrows into stay rows ("Always. PostHog's send doesn't take it yet"); an.icsfile sent as an attachment now stays too, and anenvelopeis compared with the message'sfromandtoonly. It drops the "Attachments by path" section, the attachment conversion bullets, andcc,bccand attachments from the PythonEmailMessage, Rubymailand Go call shapes. Theemail-templatesmerge becomes onedefaultsDeepof the site's message over the config's, since a moved site has no attachments to pick between. The row for a language without a PostHog SDK that sends email now names Java's Jakarta Mail or Kotlin and points at Step 7's list. The forward merge below it brings feat(migrate-email): move Resend sends in every PostHog SDK's language #472's rewrite files and Step 7's list of what every SDK's send takes. The same headless dry run, on the same fixture, now runs with answers that no longer say an SDK takescc,bccor attachments. The expected outcome was written before the run:receipt.tsstays on cc and attachments, and every other site lands as in the last run. Result: 12 found, 5 moved, 7 left, as expected.receipt.tsstays with the reason "ccand a PDF attachment". The plan uses thelanguagecolumn, the run never namesposthog-sdks.md, and the fixture was unchanged.Dry run on
1ea906f. 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 SMTP sends in TypeScript, Python, Go and PHP through a Mailgun host and a Gmail host, plus a Mailgun webhook route and a NodemailerjsonTransportpreview script that is not a site. The person's answers were in the prompt (Mailgun tracks neither opens nor clicks, and their Mailgun suppression exports); the expected outcomes were written before each run. The fixture was unchanged after the run.welcome.ts:{ name, address }recipient, catch logscodeandresponseCodename,codeandstatusCode, and the log readsstatusCode. The report notes the To line loses the namereceipt.ts:ccand a PDF Buffer attachmentccand the attachment. Logsdata.idwhere it loggedmessageId(stays from0d981d6: cc and attachments)invoice.ts:nodemailer-express-handlebarsviewinvoice.hbswith the plugin's options, shared withtransport.ts, and sends the result ashtmldigest.ts: several recipients, alistheaderdemo-booking.ts:icalEventicalEventorder-shipped.ts:messageIdstored for the webhookdeliveredevents by the Message-IDworker/password_reset.py:except smtplib.SMTPExceptionreturnsFalsePostHog can't tell a refused connection from a lost oneworker/export_ready.py: a message written by hand forsendmailworker/contact_notify.py:smtp.gmail.com, from a gmail.com addressnotifier/alert.go: gomail, text and htmlnotifier/mailer.go: gomail helper withto ...string, both callers pass one addressto string, the helper takestemplateKeyanddistinctID, and both callers intrial.goare updatedportal/password_reset.php: PHPMailer, skips the send whenSMTP_HOSTis unsetPostHog\EmailError, logs its message and returnsfalse. The skip checksPOSTHOG_PROJECT_SECRET_API_KEYwith the same log lineEvery moved site sends
captureEngagement: false, as the person's tracking answer says, and logsrecipient_suppressedand goes on. The webhook route is listed:failedandcomplainedby address map to$workflows_email_bouncedand$workflows_email_blocked, and the report names the webhook destination. Summary block:from: smtp,sites_found: 12,sites_rewritten: 6,sites_left: 6, as expected.One site changed outcome over the review, by rule. Up to
b1815fd,worker/password_reset.pymoved (12/7/5). The Python rule then changed under the standing rule (see Decisions), its expected outcome was rewritten first, and the run at this head keeps it (12/6/6). The other eleven sites match.Prod-shape. A survey of real codebases' SMTP send shapes (saved at
d651c6e, reused: the commits after it change tracking, error, renderer and routing rules, which the dry run at this head covers or Gaps names, 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 skip check on missing SMTP settings, areplyToread at its callers, a helper that takes several recipients while every caller passes one, a PHPMailer site, and a sender on a mailbox provider's domain.Gaps:
email-templates(itssendandpreviewdefaults); a host that tracks both, only one, or is unknown; a Pythonexcept SMTPExceptionthat moves; a branch on an invalid send, or on a login, connection or temporary error; Ruby's and MailKit'snetwork_errorraise; a read of Nodemailer'sinfobeyondmessageId, and the callback form; a local catcher picked by environment; a regional host;smtp.loops.so. A fixture for these is a follow-up.Decisions
nodemailer-express-handlebarsandemail-templatessites move when the moved code renders the same content first, merging locals and message as the package did.email-templateswith defaultsendorpreviewstays, since the moved send would send from every environmentvalidation_error. A failed login, a connection error or a temporary refusal keeps the site, since PostHog has no counterpartexcept SMTPExceptionnetwork_errorbroke a handler that returned on a lost connection, and handing it to that handler swallowed a refused one. Replaced both earlier rules; the fixture'sworker/password_reset.pynow stayscaptureEngagement: false, mixed or unknown stays. Replaced the first version, which let PostHog start capturing what the host didn'tstatusCode, neverresponseCode, since SMTP code reads that as an SMTP replyinfomessageIdbecomes PostHog'sdata.id; any other field, such asenvelopeorresponse, keeps the site, since PostHog answers only the idwizard migrate smtporwizard skill migrate-email-smtp;smtp.loops.sosends go towizard migrate loopsemail-templatesattachment selectionsend, context-mill has no harness that runs an agent on a fixture, and the missing fixture site is under Gaps. The judge's repeat is accepted under the standing rule. From0d981d6the selection is gone: a moved site has no attachmentsInvolvement: qa-swarm, 2 panel rounds (round 1: fresh-eyes, correctness, risk, design, anti-overengineering, sweep; round 2: correctness, fresh-eyes, risk, sweep) and 16 final reads, the last at
0d981d6, with 2 findings, one on this round's mapping change and one on #472's tests, both answered. Proof captured: 17 dry runs over the review, the last at0d981d6, a prod-shape survey, and 4 judge runs.🤖 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