Skip to content

[PHP-wasm] Capture outbound email via withSMTPSink#2585

Open
adamziel wants to merge 26 commits intotrunkfrom
with-smtp-sink
Open

[PHP-wasm] Capture outbound email via withSMTPSink#2585
adamziel wants to merge 26 commits intotrunkfrom
with-smtp-sink

Conversation

@adamziel
Copy link
Copy Markdown
Collaborator

@adamziel adamziel commented Sep 2, 2025

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 new withSMTPSink() option.

We want a single, runtime-agnostic interception point that:

  • Catches every code path WordPress uses to send mail.
  • Works the same in browser and Node.
  • Hands a fully-parsed CaughtMessage to 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:

  1. mail() → sendmail. PHP's mail() pipes a fully-formed RFC 5322 message to the binary in sendmail_path. We intercept this at the spawnProcess hook with createSendmailSpawnHandler (packages/php-wasm/util/src/lib/create-sendmail-handler.ts). It matches any command whose basename is sendmail, drains stdin, and passes the raw message through parseMessage() to produce a CaughtMessage. The handler extracts the envelope sender from the -f command-line flag (if present) and passes it to parseMessage(), which merges recipients from To/Cc/Bcc headers. Non-sendmail commands are forwarded to the previous spawn handler. Messages exceeding maxSize (default 10 MB, matching SmtpSink) are rejected with an error and exit code 1.
  2. Direct SMTP (PHPMailer, fsockopen, etc.). These open a real TCP socket, which Emscripten routes through the websocket.decorator hook. We wrap that hook so that connections whose Emscripten port query parameter matches the configured SMTP port are short-circuited into an SmtpSinkWebSocket (packages/php-wasm/util/src/lib/smtp-sink-websocket.ts) that pipes bytes through an in-process SmtpSink (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 EmscriptenOptions by withSMTPSink (packages/php-wasm/universal/src/lib/with-smtp-sink.ts), which works in both runtimes because both hooks are part of the shared EmscriptenOptions surface.

SmtpSink — minimal SMTP server

A from-scratch SMTP receiver in packages/php-wasm/util/src/lib/smtp.ts that speaks enough of RFC 5321 to satisfy real clients: EHLO/HELO, MAIL FROM, RCPT TO, DATA with dot-stuffing, QUIT, plus optional AUTH PLAIN/AUTH LOGIN (advertising and validation are configurable). It runs over a ByteDuplex (a pair of ReadableStream/WritableStream) so it has no Node or browser dependency. STARTTLS is 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) and createSendmailSpawnHandler (for mail()-path messages) funnel through the shared parseMessage() function to produce a CaughtMessage from raw RFC 5322 bytes.

SmtpSinkWebSocket

SmtpSinkWebSocket (packages/php-wasm/util/src/lib/smtp-sink-websocket.ts) extends WebSocketShim and wraps an SmtpSink on a loopback pair. It buffers any send() calls that arrive while readyState is still CONNECTING and flushes them once emitOpen() fires. Sends after close emit an error rather than silently dropping data.

WebSocketShim extracted from TCPOverFetchWebsocket

Two classes now need to look like a WebSocket to Emscripten (TCPOverFetchWebsocket and the new SmtpSinkWebSocket), so the WebSocket-shaped surface — readyState, both browser-style (onmessage/addEventListener) and Node-style (on('message', …)) listener APIs, and the emit* helpers — was extracted into a shared WebSocketShim (packages/php-wasm/util/src/lib/websocket-shim.ts) base class. TCPOverFetchWebsocket now extends it.

Websocket decorator: constructor-return trick

The smtpDecorator in withSMTPSink uses a constructor-return pattern: when the port matches, the constructor returns a new SmtpSinkWebSocket(…) directly, which bypasses this and the super() call entirely — avoiding opening a real WebSocket connection to the SMTP port.

PHP mail() patching

PHP's ext/standard/mail.c calls libc popen() directly, which bypasses our wasm_popen JS spawn handler. The Dockerfile now patches mail.c to use VCWD_POPEN (already routed through wasm_popen) and a new wasm_pclose instead of libc pclose:

  • libc pclose doesn't work with the FILE* returned by wasm_popen (which uses fdopen over a JS-side pipe), so we need a matching close.
  • wasm_pclose in packages/php-wasm/compile/php/php_wasm.c tracks the PID returned by js_open_process, calls fclose on the pipe, then waits via js_waitpid and stores the exit code in FG(pclose_ret) so PHP sees the right value.
  • php_mail, wasm_pclose, and zif_mail are 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) loadRuntime now accept withSMTPSink: { port, onEmail } and merge it into the Emscripten options.

TODO

  • Add support for listening to email events in the Playground Website and CLI (separate PR)
  • Build a UI for reading emails and add it to the Playground Website and CLI (separate PR)

Testing Instructions

  • CI

Known limitations / out of scope

  • No STARTTLS. Clients must use plain SMTP against the sink. See the rationale in packages/php-wasm/util/src/lib/smtp.ts.
  • wasm_pclose uses single-global state. wasm_popen_last_pid and wasm_pclose_ret in packages/php-wasm/compile/php/php_wasm.c are file-scope globals, so concurrent writable popen() calls would clobber each other's PID and exit code. Safe today because mail() 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.

@adamziel adamziel added the [Type] Enhancement New feature or request label Sep 2, 2025
@adamziel adamziel closed this Oct 16, 2025
@bgrgicak bgrgicak reopened this Apr 7, 2026
@bgrgicak bgrgicak self-assigned this Apr 7, 2026
@bgrgicak
Copy link
Copy Markdown
Collaborator

bgrgicak commented Apr 9, 2026

I updated the tests to cover all implemented SMTP features.

After that, I added a test to verify that PHP mail function calls are correctly caught.
Turns out that PHP mail uses /usr/sbin/sendmail, so it fails with Warning: mail(): Could not execute mail delivery program '/usr/sbin/sendmail -t -i'

Here are a few options I'm exploring with Claude.

A. Sendmail emulation
mail() → popen("/usr/sbin/sendmail") → wasm_popen → js_open_process → spawnProcess → createSendmailSpawnHandler collects stdin → parses raw email → callback

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
mail() → __wrap_php_mail → js_smtp_send_mail creates decorated WebSocket → speaks SMTP (EHLO/MAIL FROM/RCPT TO/DATA/QUIT) → SmtpSink receives → callback

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
mail() → __wrap_php_mail → synchronous EM_JS calls Module.onMailSent(to, subject, message, headers) → TypeScript builds CaughtMessage → 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.

bgrgicak and others added 5 commits April 10, 2026 13:40
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>
@bgrgicak bgrgicak changed the title WIP: withSMTPSink [PHP-wasm] Capture outbound email via withSMTPSink Apr 10, 2026
@bgrgicak
Copy link
Copy Markdown
Collaborator

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(),
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do that universally for all popen calls in all the C code we use?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Context https://github.com/WordPress/wordpress-playground/pull/2585/changes#diff-7c2ff9d253962b8819bd036427a04e7383e8d190b6e9d4f9d54bfe8f9fcf9ca2R579-R588

Copy link
Copy Markdown
Collaborator

@bgrgicak bgrgicak Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment on lines +6 to +9
// TODO re-enable testing on all versions before merging
// 'PHP' in process.env
// ? [process.env['PHP']! as SupportedPHPVersion]
// : SupportedPHPVersions;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I plan to work on the UI in a follow up PR.

bgrgicak and others added 2 commits April 13, 2026 10:39
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>
Comment on lines +203 to +208
// 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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

bgrgicak and others added 3 commits April 13, 2026 11:26
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>
bgrgicak and others added 6 commits April 13, 2026 12:35
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>
@bgrgicak bgrgicak marked this pull request as ready for review April 13, 2026 11:38
@bgrgicak bgrgicak requested review from a team, bgrgicak and Copilot April 13, 2026 11:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 createSendmailSpawnHandler to capture mail()/sendmail-path messages via the spawn hook.
  • Extracts WebSocketShim and updates TCP-over-fetch WebSocket implementation to extend it; wires withSMTPSink into 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(): data is typed as ArrayBuffer | Uint8Array | string but is always cast to ArrayBuffer. This will mis-buffer protocol-detection bytes (and may throw) when send() is called with a Uint8Array or string. Normalize data to a Uint8Array once and reuse it here and in the upstream write.
/**

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts Outdated
Comment thread packages/php-wasm/util/src/lib/websocket-shim.ts
Comment thread packages/php-wasm/util/src/lib/create-sendmail-handler.ts
Comment thread packages/php-wasm/util/src/lib/smtp.ts Outdated
Comment thread packages/php-wasm/node/src/test/php-smtp.spec.ts
Comment thread packages/php-wasm/util/src/lib/smtp-sink-websocket.ts
@bgrgicak
Copy link
Copy Markdown
Collaborator

@adamziel pinging you for a review here because I can't assign you.

@bgrgicak bgrgicak requested review from brandonpayton and removed request for bgrgicak April 14, 2026 08:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants