[PHP-wasm] Capture outbound email via withSMTPSink#2585
[PHP-wasm] Capture outbound email via withSMTPSink#2585
Conversation
|
I updated the tests to cover all implemented SMTP features. After that, I added a test to verify that PHP Here are a few options I'm exploring with Claude. A. Sendmail emulation PHP pipes the fully-formed email to a fake sendmail process. The spawn handler collects everything written to stdin, parses it, and delivers it to the callback. B. SMTP over WebSocket An async EM_JS function opens a WebSocket (intercepted by the decorator), performs a full SMTP handshake, and sends the email as if talking to a real mail server. The email gets reconstructed from parts in JS, then parsed again by SmtpSink. C. Direct JS callback A synchronous EM_JS passes the four mail() arguments straight to a callback on the Module object. No protocol, no process spawning, no async — TypeScript assembles the CaughtMessage directly from the parts PHP already gave us. |
Move the shared WebSocket-like interface (readyState, event listeners, emit helpers) into a reusable WebSocketShim base class in @php-wasm/util so both TCPOverFetchWebsocket and the upcoming SmtpSinkWebSocket can extend it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Patch ext/standard/mail.c so that PHP's mail() function uses VCWD_POPEN (routed through wasm_popen → JS spawn handler) instead of raw popen(). Add wasm_pclose() to properly wait for the spawned sendmail process and retrieve its exit code. Includes the recompiled PHP 8.4 binary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement an in-process SMTP server (SmtpSink) that receives emails over a loopback byte stream. Add SmtpSinkWebSocket to intercept Emscripten's TCP-over-WebSocket traffic, and createSendmailSpawnHandler to catch PHP mail() calls that shell out to /usr/sbin/sendmail. Includes 58 tests covering RFC 5321 compliance, SASL AUTH (PLAIN/LOGIN), MIME parsing, dot-stuffing, size limits, and edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
withSMTPSink now accepts existing EmscriptenOptions and returns merged options, handling websocket decorator chaining and spawn handler fallback internally. Both load-runtime files are simplified to a single function call. Adds PHP integration tests for mail(), proc_open sendmail, and fsockopen SMTP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The PR works, and emails sent by PHP are caught by the sink and correctly parsed. See tests here. The PR is large, and I need to do another review of it, but feel free to take a look and leave feedback. |
| RUN echo 'extern FILE *wasm_popen(const char *cmd, const char *mode);' >> /root/php-src/Zend/zend_virtual_cwd.h | ||
|
|
||
| # Patch mail.c for Emscripten compatibility: | ||
| # 1. Use VCWD_POPEN (routed through wasm_popen) instead of raw popen(), |
There was a problem hiding this comment.
Could we do that universally for all popen calls in all the C code we use?
There was a problem hiding this comment.
Not a blocker here btw, it would just be useful. There's a CLI flag that wraps C function calls, we use it somewhere in this Dockerfile. Was it -Wl,--wrap=symbol?
There was a problem hiding this comment.
Yes, but I would prefer to first enable PHP-WASM to support concurrent writable popen calls before switching all popen and pclose calls to the PHP-WASM implementation.
Let's do it as a follow-up PR.
There was a problem hiding this comment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // TODO re-enable testing on all versions before merging | ||
| // 'PHP' in process.env | ||
| // ? [process.env['PHP']! as SupportedPHPVersion] | ||
| // : SupportedPHPVersions; |
There was a problem hiding this comment.
I will recompile all PHP versions after code review to prevent the PR from crashing in GitHub
SMTP protocol response codes are already tested in the dedicated smtp.test.ts suite, so this integration test only needs to confirm the email arrives correctly via fsockopen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| { port, onEmail }: WithSmtpSinkOptions, | ||
| emscriptenOptions: EmscriptenOptions = {} | ||
| ): EmscriptenOptions { | ||
| // TODO: Provide a way for the Playground website to read received messages. |
There was a problem hiding this comment.
I plan to work on the UI in a follow up PR.
pipeTo() already delivers chunks asynchronously, so the SMTP 220 greeting cannot arrive before Emscripten attaches its handlers. The explicit microtask deferral was unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both catches swallowed errors for scenarios that can't actually occur: the pipeTo() resolves normally on clean shutdown, and the writer can't already be closed since the peer only closes its own side of the loopback. Removing them surfaces real bugs instead of hiding them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // RFC 5321 §4.1.1.1 ABNF: | ||
| // ehlo-ok-rsp = "250-" Domain [ SP ehlo-greet ] CRLF | ||
| // *( "250-" ehlo-line CRLF ) | ||
| // "250" SP ehlo-line CRLF | ||
| // The first token after "250-" MUST be the server's Domain; | ||
| // any free-form `ehlo-greet` follows after a single SP. |
There was a problem hiding this comment.
Do you find these comments valuable?
They help me review the tests without needing to go back to the spec, so I left them, but happy to remove them if it's noise for others.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…in SmtpSink Inline reply334() into the generic reply() method, drop the .catch() in enqueue() along with the logger import, and clean up remaining section-header comments and verbose test comments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The callback already awaits stdinDone before reaching exit(0), so createSpawnHandler's synchronous-exit check never triggers. The setTimeout was never needed here. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sendmail stdin handler had no size limit while SmtpSink defaults to 10 MB. Add the same default maxSize and reject oversized messages with a stderr diagnostic and exit code 1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The close() method was calling emitClose() directly, jumping straight to CLOSED and skipping the CLOSING state. This is inconsistent with the WebSocket spec and with SmtpSinkWebSocket.close(). Add a guard against double-close and set readyState = CLOSING before emitting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…inkWebSocket Sends arriving before emitOpen() are now buffered and flushed when the socket opens, instead of being silently dropped. Sends after close emit an error so callers can detect misuse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… handler parseMessage now collects recipients from To, CC, and BCC headers itself, so the sendmail handler no longer duplicates that logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a runtime-agnostic outbound-email interception mechanism for PHP.wasm by introducing an in-process SMTP sink (for direct SMTP clients) and a sendmail spawn handler (for mail()), and wiring it into both Web and Node runtimes via withSMTPSink().
Changes:
- Introduces
SmtpSink(minimal SMTP receiver),SmtpSinkWebSocket, and shared RFC5322 parsing utilities. - Adds
createSendmailSpawnHandlerto capturemail()/sendmail-path messages via the spawn hook. - Extracts
WebSocketShimand updates TCP-over-fetch WebSocket implementation to extend it; wireswithSMTPSinkinto web/node runtime loaders and adds tests.
Reviewed changes
Copilot reviewed 18 out of 20 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts | Refactors TCP-over-fetch WebSocket to extend shared shim and use new emit helpers. |
| packages/php-wasm/web/src/lib/load-runtime.ts | Adds withSMTPSink option to web runtime loader. |
| packages/php-wasm/util/src/lib/websocket-shim.ts | Adds shared WebSocket-shaped base class for Emscripten interception. |
| packages/php-wasm/util/src/lib/smtp.ts | Implements SMTP sink, RFC5321 handling, and RFC5322 parsing helpers. |
| packages/php-wasm/util/src/lib/smtp.test.ts | Adds thorough unit tests for SMTP sink and message parsing. |
| packages/php-wasm/util/src/lib/smtp-sink-websocket.ts | Adds WebSocket-shaped adapter that routes bytes to SmtpSink. |
| packages/php-wasm/util/src/lib/index.ts | Re-exports SMTP and WebSocket shim utilities. |
| packages/php-wasm/util/src/lib/create-sendmail-handler.ts | Adds spawn handler to capture sendmail (PHP mail()) output. |
| packages/php-wasm/universal/src/lib/with-smtp-sink.ts | Adds withSMTPSink() to combine spawn + websocket interception. |
| packages/php-wasm/universal/src/lib/index.ts | Exports withSMTPSink. |
| packages/php-wasm/node/src/lib/load-runtime.ts | Adds withSMTPSink option to node runtime loader. |
| packages/php-wasm/node/src/test/php-smtp.spec.ts | Adds node integration tests for sendmail, SMTP, and mail(). |
| packages/php-wasm/node/project.json | Includes new SMTP spec in test targets. |
| packages/php-wasm/compile/php/php_wasm.c | Adds wasm_pclose to match wasm_popen for correct process waiting. |
| packages/php-wasm/compile/php/Dockerfile | Patches PHP mail.c to use VCWD_POPEN + wasm_pclose, updates asyncify symbols. |
| packages/php-wasm/supported-php-versions.mjs | Updates PHP release metadata. |
| packages/php-wasm/node-builds/8-4/*/php_8_4.js | Updates generated node build metadata and exports/imports for wasm_pclose. |
Comments suppressed due to low confidence (1)
packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts:1
- This has the same type-casting issue as
send():datais typed asArrayBuffer | Uint8Array | stringbut is always cast toArrayBuffer. This will mis-buffer protocol-detection bytes (and may throw) whensend()is called with aUint8Arrayorstring. Normalizedatato aUint8Arrayonce and reuse it here and in the upstream write.
/**
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@adamziel pinging you for a review here because I can't assign you. |
Motivation for the change, related issues
WordPress and plugins try to send email, but in Playground, there's no way to inspect what would have been sent. Today, those attempts either silently fail or escape to the host. This PR adds an in-process SMTP sink that captures all outbound email from PHP — both
mail()calls and direct SMTP connections (PHPMailer,fsockopen, etc.) — without ever touching the network, and wires it into both the Web and Node PHP runtimes via a newwithSMTPSink()option.We want a single, runtime-agnostic interception point that:
CaughtMessageto the embedder (so the website can display a "Sent mail" inbox, tests can assert on outgoing mail, etc.).Implementation details
Two interception points
PHP code reaches the network in two distinct ways for mail, and both need to be caught:
mail()→ sendmail. PHP'smail()pipes a fully-formed RFC 5322 message to the binary insendmail_path. We intercept this at thespawnProcesshook withcreateSendmailSpawnHandler(packages/php-wasm/util/src/lib/create-sendmail-handler.ts). It matches any command whose basename issendmail, drains stdin, and passes the raw message throughparseMessage()to produce aCaughtMessage. The handler extracts the envelope sender from the-fcommand-line flag (if present) and passes it toparseMessage(), which merges recipients from To/Cc/Bcc headers. Non-sendmail commands are forwarded to the previous spawn handler. Messages exceedingmaxSize(default 10 MB, matchingSmtpSink) are rejected with an error and exit code 1.fsockopen, etc.). These open a real TCP socket, which Emscripten routes through thewebsocket.decoratorhook. We wrap that hook so that connections whose Emscriptenportquery parameter matches the configured SMTP port are short-circuited into anSmtpSinkWebSocket(packages/php-wasm/util/src/lib/smtp-sink-websocket.ts) that pipes bytes through an in-processSmtpSink(packages/php-wasm/util/src/lib/smtp.ts) instead of opening a connection. The port check reads the?port=search param on the WebSocket URL (Emscripten encodes the target TCP port there, not in the URL's actual port field).Both hooks are merged into a single
EmscriptenOptionsbywithSMTPSink(packages/php-wasm/universal/src/lib/with-smtp-sink.ts), which works in both runtimes because both hooks are part of the sharedEmscriptenOptionssurface.SmtpSink— minimal SMTP serverA from-scratch SMTP receiver in
packages/php-wasm/util/src/lib/smtp.tsthat speaks enough of RFC 5321 to satisfy real clients:EHLO/HELO,MAIL FROM,RCPT TO,DATAwith dot-stuffing,QUIT, plus optionalAUTH PLAIN/AUTH LOGIN(advertising and validation are configurable). It runs over aByteDuplex(a pair ofReadableStream/WritableStream) so it has no Node or browser dependency.STARTTLSis intentionally not supported — the loopback duplex carries no real network traffic and there's nothing to encrypt; clients that demand TLS should be configured for plain SMTP against this sink.Both
SmtpSink(for SMTP-path messages) andcreateSendmailSpawnHandler(formail()-path messages) funnel through the sharedparseMessage()function to produce aCaughtMessagefrom raw RFC 5322 bytes.SmtpSinkWebSocketSmtpSinkWebSocket(packages/php-wasm/util/src/lib/smtp-sink-websocket.ts) extendsWebSocketShimand wraps anSmtpSinkon a loopback pair. It buffers anysend()calls that arrive whilereadyStateis stillCONNECTINGand flushes them onceemitOpen()fires. Sends after close emit an error rather than silently dropping data.WebSocketShimextracted fromTCPOverFetchWebsocketTwo classes now need to look like a WebSocket to Emscripten (
TCPOverFetchWebsocketand the newSmtpSinkWebSocket), so the WebSocket-shaped surface —readyState, both browser-style (onmessage/addEventListener) and Node-style (on('message', …)) listener APIs, and theemit*helpers — was extracted into a sharedWebSocketShim(packages/php-wasm/util/src/lib/websocket-shim.ts) base class.TCPOverFetchWebsocketnow extends it.Websocket decorator: constructor-return trick
The
smtpDecoratorinwithSMTPSinkuses a constructor-return pattern: when the port matches, the constructor returns anew SmtpSinkWebSocket(…)directly, which bypassesthisand thesuper()call entirely — avoiding opening a real WebSocket connection to the SMTP port.PHP
mail()patchingPHP's
ext/standard/mail.ccalls libcpopen()directly, which bypasses ourwasm_popenJS spawn handler. The Dockerfile now patchesmail.cto useVCWD_POPEN(already routed throughwasm_popen) and a newwasm_pcloseinstead of libcpclose:pclosedoesn't work with theFILE*returned bywasm_popen(which usesfdopenover a JS-side pipe), so we need a matching close.wasm_pcloseinpackages/php-wasm/compile/php/php_wasm.ctracks the PID returned byjs_open_process, callsfcloseon the pipe, then waits viajs_waitpidand stores the exit code inFG(pclose_ret)so PHP sees the right value.php_mail,wasm_pclose, andzif_mailare added to the asyncify imports/exports lists.Runtime wiring
Both web (
packages/php-wasm/web/src/lib/load-runtime.ts) and node (packages/php-wasm/node/src/lib/load-runtime.ts)loadRuntimenow acceptwithSMTPSink: { port, onEmail }and merge it into the Emscripten options.TODO
Testing Instructions
Known limitations / out of scope
STARTTLS. Clients must use plain SMTP against the sink. See the rationale inpackages/php-wasm/util/src/lib/smtp.ts.wasm_pcloseuses single-global state.wasm_popen_last_pidandwasm_pclose_retinpackages/php-wasm/compile/php/php_wasm.care file-scope globals, so concurrent writablepopen()calls would clobber each other's PID and exit code. Safe today becausemail()is the only caller and it does a strict open-write-close sequence; a proper fix would stash both in a table keyed by fd.