Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
beb1296
WIP: withSMTPSink
adamziel Sep 2, 2025
1de7af9
Add tests for SmtpSink EHLO, AUTH, STARTTLS, and parseEmail
bgrgicak Apr 9, 2026
659d2b6
Fix linter errors
bgrgicak Apr 9, 2026
efcde45
Add PHP mail tests
bgrgicak Apr 9, 2026
3b924b0
Extract WebSocketShim from TCPOverFetchWebsocket
bgrgicak Apr 10, 2026
3348dff
Patch PHP mail() to route through wasm_popen/wasm_pclose
bgrgicak Apr 10, 2026
7ca6b56
Add SMTP sink, sendmail handler, and SmtpSinkWebSocket
bgrgicak Apr 10, 2026
78bacf2
Wire withSMTPSink into web and node runtimes
bgrgicak Apr 10, 2026
e06454b
Refresh PHP versions (8.4.20, 8.5.5)
bgrgicak Apr 10, 2026
0aaf687
Add TODO for handling concurrent calls in wasm_popen
bgrgicak Apr 10, 2026
62c440b
mak emit helpers public in WebSocketShim
bgrgicak Apr 10, 2026
a3c2b74
Recompile PHP 8.4 JSPI node binary to include mail() popen patch
bgrgicak Apr 10, 2026
7f373f7
Simplify fsockopen SMTP test to only verify email delivery
bgrgicak Apr 13, 2026
b26a835
Remove redundant queueMicrotask in SmtpSinkWebSocket
bgrgicak Apr 13, 2026
6905989
Remove silent .catch() handlers in SmtpSinkWebSocket
bgrgicak Apr 13, 2026
062ab31
Remove redundant comments in SMTP sink tests
bgrgicak Apr 13, 2026
001ce4a
Remove redundant comments that restate what the code already says
bgrgicak Apr 13, 2026
aefb381
Remove reply334 helper, redundant comments, and silent error handler …
bgrgicak Apr 13, 2026
579f242
Remove unnecessary setTimeout workaround in sendmail handler
bgrgicak Apr 13, 2026
94297ef
Remove wrong filename comment and section dividers in SMTP sink
bgrgicak Apr 13, 2026
cc3d98a
Enforce maxSize in sendmail handler to match SmtpSink's limit
bgrgicak Apr 13, 2026
730b5b4
Fix TCPOverFetchWebsocket.close() to transition through CLOSING state
bgrgicak Apr 13, 2026
55b1027
Buffer sends during CONNECTING and error on send after close in SmtpS…
bgrgicak Apr 13, 2026
b833fde
Move CC/BCC recipient merging into parseMessage and simplify sendmail…
bgrgicak Apr 13, 2026
a11bef7
support string data in TCPOverFetchWebsocket
bgrgicak Apr 14, 2026
85e0184
Use default TextDecoder arguments in SmtpSink
bgrgicak Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/php-wasm/compile/php/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ RUN /root/replace.sh 's/PHPAPI int php_exec(.+)$/PHPAPI extern int php_exec\1; i
RUN /root/replace.sh 's/#define VCWD_POPEN.+/#define VCWD_POPEN(command, type) wasm_popen(command,type)/g' /root/php-src/Zend/zend_virtual_cwd.h
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.

# so that PHP's mail() function goes through our JS spawn handler.
# 2. Use wasm_pclose() instead of pclose() so we wait for the spawned
# process to finish and get a correct exit code. Libc's pclose() doesn't
# work with FILE handles created by wasm_popen (fdopen).
RUN sed -i '1i #include <stdio.h>\nextern int wasm_pclose(FILE *fp);' /root/php-src/ext/standard/mail.c
RUN perl -pi.bak -e 's/\bpopen\s*\(\s*sendmail_cmd\s*,/VCWD_POPEN(sendmail_cmd,/g; s/\bpclose\s*\(\s*sendmail\s*\)/wasm_pclose(sendmail)/g' /root/php-src/ext/standard/mail.c

# Provide a custom implementation of the shutdown() function.
RUN perl -pi.bak -e $'s/(\s+)shutdown\(/$1 wasm_shutdown(/g' /root/php-src/sapi/cli/php_cli_server.c
RUN perl -pi.bak -e $'s/(\s+)closesocket\(/$1 wasm_close(/g' /root/php-src/sapi/cli/php_cli_server.c
Expand Down Expand Up @@ -711,6 +720,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\
"php_init_config",\
"zend_register_constant",\
"zend_register_ini_entries_ex",\
"php_mail",\
"php_module_startup",\
"wasm_sapi_module_startup",\
"__fseeko_unlocked",\
Expand Down Expand Up @@ -1664,6 +1674,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\
"wasm_php_exec",\
"wasm_php_stream_flush",\
"wasm_php_stream_read",\
"wasm_pclose",\
"wasm_popen",\
"wasm_read",\
"wasm_sapi_handle_request",\
Expand Down Expand Up @@ -2028,6 +2039,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\
"zif_passthru",\
"zif_phar_file_get_contents",\
"zif_phar_fopen",\
"zif_mail",\
"zif_popen",\
"zif_post_message_to_js",\
"zif_preg_replace_callback",\
Expand Down Expand Up @@ -2234,7 +2246,7 @@ RUN set -euxo pipefail; \
source /root/emsdk/emsdk_env.sh; \
if [ "$WITH_JSPI" = "yes" ]; then \
# Both imports and exports are required for inter-module communication with wrapped methods, e.g., wasm_recv.
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_fd_read,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv,wasm_connect,__syscall_fcntl64,js_flock,js_release_file_locks,js_waitpid -sJSPI_EXPORTS=php_wasm_init,wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli,wasm_recv,wasm_connect,__wasm_call_ctors,__errno_location,__funcs_on_exit -s EXPORTED_RUNTIME_METHODS=HEAPU32,HEAPU8,ccall,PROXYFS,wasmExports "; \
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_fd_read,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv,wasm_connect,__syscall_fcntl64,js_flock,js_release_file_locks,js_waitpid -sJSPI_EXPORTS=php_wasm_init,wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_pclose,wasm_read,wasm_php_exec,run_cli,wasm_recv,wasm_connect,__wasm_call_ctors,__errno_location,__funcs_on_exit -s EXPORTED_RUNTIME_METHODS=HEAPU32,HEAPU8,ccall,PROXYFS,wasmExports "; \
echo '#define PLAYGROUND_JSPI 1' > /root/php_wasm_asyncify.h; \
else \
export ASYNCIFY_FLAGS=" -s ASYNCIFY=1 -s ASYNCIFY_IGNORE_INDIRECT=1 -s EXPORTED_RUNTIME_METHODS=HEAPU32,HEAPU8,ccall,PROXYFS,wasmExports,UTF8ToString,lengthBytesUTF8,stringToUTF8 $(cat /root/.emcc-php-asyncify-flags) "; \
Expand Down
36 changes: 32 additions & 4 deletions packages/php-wasm/compile/php/php_wasm.c
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,10 @@ extern int __wasi_syscall_ret(__wasi_errno_t code);
// Exit code of the last exited child process call.
int wasm_pclose_ret = -1;

// PID of the last process spawned by wasm_popen("w").
// Used by wasm_pclose to wait for the process to finish.
static int wasm_popen_last_pid = -1;

/**
* Passes a message to the JavaScript module and writes the response
* data, if any, to the response_buffer pointer.
Expand Down Expand Up @@ -515,7 +519,7 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode)
return 0;
}

fp = fdopen(stdin_pipe[1], "w"); // or "w", depending on direction
fp = fdopen(stdin_pipe[1], "w");
if (!fp) {
php_error_docref(NULL, E_WARNING, "unable to create pipe %s", strerror(errno));
errno = EINVAL;
Expand Down Expand Up @@ -544,8 +548,7 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode)
descv[1] = stdout;
descv[2] = stderr;

// the wasm way {{{
js_open_process(
wasm_popen_last_pid = js_open_process(
cmd,
NULL,
0,
Expand All @@ -556,7 +559,6 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode)
0,
0
);
// }}}

efree(stdin);
efree(stdout);
Expand All @@ -574,6 +576,32 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode)
return fp;
}

/**
* Close a FILE* created by wasm_popen and wait for the spawned process
* to exit. Returns the process exit code, or -1 on error.
*
* @TODO wasm_popen_last_pid and wasm_pclose_ret are single globals,
* so concurrent writable popen() calls will 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, but a proper fix
* would stash both in a table keyed by fd.
*/
extern int js_waitpid(int pid, int *exitcode);

EMSCRIPTEN_KEEPALIVE int wasm_pclose(FILE *fp)
{
int pid = wasm_popen_last_pid;
fclose(fp);
if (pid < 0) {
return -1;
}
int wstatus = 0;
js_waitpid(pid, &wstatus);
wasm_pclose_ret = wstatus;
FG(pclose_ret) = wstatus;
return wstatus;
}

/**
* Ship php_exec, the function powering the following PHP
* functions:
Expand Down
Binary file not shown.
99 changes: 50 additions & 49 deletions packages/php-wasm/node-builds/8-4/asyncify/php_8_4.js

Large diffs are not rendered by default.

Binary file not shown.
111 changes: 56 additions & 55 deletions packages/php-wasm/node-builds/8-4/jspi/php_8_4.js

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/php-wasm/node/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@
"php-imagick.spec.ts",
"php-soap.spec.ts",
"php-image-extensions.spec.ts",
"php-fsockopen.spec.ts"
"php-fsockopen.spec.ts",
"php-smtp.spec.ts"
]
}
},
Expand All @@ -184,7 +185,8 @@
"php-imagick.spec.ts",
"php-soap.spec.ts",
"php-image-extensions.spec.ts",
"php-fsockopen.spec.ts"
"php-fsockopen.spec.ts",
"php-smtp.spec.ts"
]
}
},
Expand Down
9 changes: 9 additions & 0 deletions packages/php-wasm/node/src/lib/load-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
import { withIntl } from './extensions/intl/with-intl';
import { withRedis } from './extensions/redis/with-redis';
import { withMemcached } from './extensions/memcached/with-memcached';
import { withSMTPSink } from '@php-wasm/universal';
import { dirname, joinPaths, toPosixPath } from '@php-wasm/util';
import type { CaughtMessage } from '@php-wasm/util';
import { platform } from 'os';

export interface PHPLoaderOptions {
Expand All @@ -31,6 +33,7 @@ export interface PHPLoaderOptions {
withIntl?: boolean;
withRedis?: boolean;
withMemcached?: boolean;
withSMTPSink?: { port: number; onEmail: (m: CaughtMessage) => void };
}

export type PHPLoaderOptionsForNode = PHPLoaderOptions & {
Expand Down Expand Up @@ -308,6 +311,12 @@ export async function loadNodeRuntime(
}

emscriptenOptions = await withNetworking(emscriptenOptions);
if (options?.withSMTPSink) {
emscriptenOptions = withSMTPSink(
options.withSMTPSink,
emscriptenOptions
);
}

const phpLoaderModule = await getPHPLoaderModule(phpVersion);

Expand Down
151 changes: 151 additions & 0 deletions packages/php-wasm/node/src/test/php-smtp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { PHP, setPhpIniEntries } from '@php-wasm/universal';
import type { CaughtMessage } from '@php-wasm/util';
import { loadNodeRuntime } from '../lib';

const phpVersions = ['8.4'];
// TODO re-enable testing on all versions before merging
// 'PHP' in process.env
// ? [process.env['PHP']! as SupportedPHPVersion]
// : SupportedPHPVersions;
Comment on lines +6 to +9
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

Comment thread
bgrgicak marked this conversation as resolved.

describe.each(phpVersions)('PHP %s – SMTP sink', (phpVersion) => {
let php: PHP;
let emails: CaughtMessage[];

beforeEach(async () => {
emails = [];
php = new PHP(
await loadNodeRuntime(phpVersion as any, {
withSMTPSink: {
port: 25,
onEmail: (m: CaughtMessage) => emails.push(m),
},
})
);
await setPhpIniEntries(php, {
disable_functions: '',
allow_url_fopen: 1,
});
}, 30_000);

afterEach(() => {
php?.exit();
});

it('captures an email piped via proc_open to sendmail', async () => {
const result = await php.run({
code: `<?php
error_reporting(E_ALL);
$email = "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: Hello from PHP\r\n\r\nThis is the body.";
$proc = proc_open(
'/usr/sbin/sendmail -t -i',
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
$pipes
);
if (!is_resource($proc)) {
echo 'PROC_OPEN_FAILED';
exit;
}
fwrite($pipes[0], $email);
fclose($pipes[0]);
$exit = proc_close($proc);
echo $exit === 0 ? 'SENT' : 'EXIT_' . $exit;
`,
});

expect(result.text).toBe('SENT');
expect(emails).toHaveLength(1);
expect(emails[0].from).toContain('sender@test.com');
expect(emails[0].to).toContain('recipient@test.com');
expect(emails[0].subject).toBe('Hello from PHP');
expect(emails[0].text?.trim()).toBe('This is the body.');
});

it('captures an email sent via fsockopen SMTP', async () => {
const result = await php.run({
code: `<?php
error_reporting(E_ALL);

// Helper: read a full SMTP reply (may be multi-line).
// Multi-line replies have a dash after the code (e.g. "250-...").
// The final line has a space (e.g. "250 ...").
function smtp_read_reply($fp) {
$lines = '';
while (($line = fgets($fp)) !== false) {
$lines .= $line;
// Final line: code followed by space (not dash)
if (preg_match('/^\\d{3} /', $line)) break;
}
return $lines;
}

$smtp = fsockopen('localhost', 25, $errno, $errstr, 5);
if (!$smtp) {
echo "CONNECT_FAILED: $errstr ($errno)";
exit;
}

// Read server greeting
$greeting = smtp_read_reply($smtp);
if (strpos($greeting, '220') !== 0) {
echo "BAD_GREETING";
fclose($smtp);
exit;
}

fwrite($smtp, "EHLO localhost\\r\\n");
smtp_read_reply($smtp);

fwrite($smtp, "MAIL FROM:<sender@test.com>\\r\\n");
smtp_read_reply($smtp);

fwrite($smtp, "RCPT TO:<recipient@test.com>\\r\\n");
smtp_read_reply($smtp);

fwrite($smtp, "DATA\\r\\n");
smtp_read_reply($smtp);

fwrite($smtp, "From: sender@test.com\\r\\n");
fwrite($smtp, "To: recipient@test.com\\r\\n");
fwrite($smtp, "Subject: Hello via SMTP\\r\\n");
fwrite($smtp, "\\r\\n");
fwrite($smtp, "This is the body.\\r\\n");
fwrite($smtp, ".\\r\\n");
smtp_read_reply($smtp);

fwrite($smtp, "QUIT\\r\\n");
fclose($smtp);
echo 'SENT';
`,
});

expect(result.text).toBe('SENT');
expect(emails).toHaveLength(1);
expect(emails[0].from).toContain('sender@test.com');
expect(emails[0].to).toContain('recipient@test.com');
expect(emails[0].subject).toBe('Hello via SMTP');
expect(emails[0].text?.trim()).toBe('This is the body.');
});

it('captures an email sent via mail()', async () => {
const result = await php.run({
code: `<?php
error_reporting(E_ALL);
$result = mail(
'recipient@test.com',
'Hello from PHP',
'This is the body.',
'From: sender@test.com'
);
echo $result ? 'SENT' : 'FAILED';
`,
});

expect(result.text).toBe('SENT');
expect(emails).toHaveLength(1);
expect(emails[0].from).toContain('sender@test.com');
expect(emails[0].to).toContain('recipient@test.com');
expect(emails[0].subject).toBe('Hello from PHP');
expect(emails[0].text?.trim()).toBe('This is the body.');
});
});
8 changes: 4 additions & 4 deletions packages/php-wasm/supported-php-versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* @property {string} lastRelease
*/

export const lastRefreshed = "2026-03-30T20:17:23.030Z";
export const lastRefreshed = '2026-04-09T08:10:42.888Z';

/**
* @type {PhpVersion[]}
Expand All @@ -17,13 +17,13 @@ export const phpVersions = [
version: '8.5',
loaderFilename: 'php_8_5.js',
wasmFilename: 'php_8_5.wasm',
lastRelease: '8.5.4',
lastRelease: '8.5.5',
},
{
version: '8.4',
loaderFilename: 'php_8_4.js',
wasmFilename: 'php_8_4.wasm',
lastRelease: '8.4.19',
lastRelease: '8.4.20',
},
{
version: '8.3',
Expand Down Expand Up @@ -54,5 +54,5 @@ export const phpVersions = [
loaderFilename: 'php_7_4.js',
wasmFilename: 'php_7_4.wasm',
lastRelease: '7.4.33',
}
},
];
1 change: 1 addition & 0 deletions packages/php-wasm/universal/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export {
export { isExitCode } from './is-exit-code';
export { proxyFileSystem, isPathToSharedFS } from './proxy-file-system';
export { sandboxedSpawnHandlerFactory } from './sandboxed-spawn-handler-factory';
export { withSMTPSink } from './with-smtp-sink';

export * from './api';
export type { WithAPIState as WithIsReady } from './api';
Expand Down
Loading
Loading