Skip to content

Commit a3c54bd

Browse files
bgrgicakadamziel
andauthored
[PHP-WASM] Expose sendmail stdin as a live event stream (#3996)
PHP can write a message to `sendmail` through `mail()`, `popen()`, or `proc_open()`, but PHP-WASM has no reusable transport for observing those bytes. This adds a sendmail-compatible null transport. It never delivers mail. It emits `sendmail.spawned` with raw stdin as a live `ReadableStream<Uint8Array>`. Registration is explicit: ```ts import { sendmailSpawnHandler } from '@php-wasm/util'; php.setCommandSpawnHandler('sendmail', sendmailSpawnHandler(php)); php.addEventListener('sendmail.spawned', async (event) => { if (event.type !== 'sendmail.spawned') { return; } const rawMessage = await new Response(event.stdin).arrayBuffer(); // Parse or relay rawMessage. }); ``` The event fires with the first stdin chunk, before PHP finishes writing. The transport stays alive until stdin closes. Empty input exits with `EX_TEMPFAIL` and emits no event. Input above 20 MB errors the stream and exits with status 1. Cancelling the stream stops delivery to that reader but keeps draining stdin, so PHP can finish writing and receive the transport's exit status. There is no backpressure to PHP; a slow listener may buffer the full message. Node tests cover `mail()`, `proc_open()`, delayed input, empty and oversized input, and runtime rotation. Browser tests cover live delivery to multiple listeners and cancellation while PHP continues writing. This PR does not install the transport by default. #4063 installs it in remote Playground instances. #4064 provides the marked stdin event transport and per-listener streams used here. Co-authored-by: Adam Zieliński <adam@adamziel.com>
1 parent 8a9a9a0 commit a3c54bd

10 files changed

Lines changed: 587 additions & 2 deletions

File tree

‎packages/php-wasm/node/project.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"php-imagick.spec.ts",
171171
"php-soap.spec.ts",
172172
"php-image-extensions.spec.ts",
173+
"php-email-capture.spec.ts",
173174
"php-fsockopen.spec.ts",
174175
"php-socket-timeouts.spec.ts"
175176
]
@@ -185,6 +186,7 @@
185186
"php-imagick.spec.ts",
186187
"php-soap.spec.ts",
187188
"php-image-extensions.spec.ts",
189+
"php-email-capture.spec.ts",
188190
"php-fsockopen.spec.ts",
189191
"php-socket-timeouts.spec.ts"
190192
]
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
PHP,
3+
setPhpIniEntries,
4+
SupportedPHPVersions,
5+
} from '@php-wasm/universal';
6+
import {
7+
SENDMAIL_CAPTURE_MAX_SIZE,
8+
sendmailSpawnHandler,
9+
} from '@php-wasm/util';
10+
import type { PHPSendmailSpawnedEvent } from '@php-wasm/util';
11+
import { loadNodeRuntime } from '../lib';
12+
13+
const phpVersions =
14+
'PHP' in process.env ? [process.env['PHP']!] : SupportedPHPVersions;
15+
16+
describe.each(phpVersions)('PHP %s - sendmail capture', (phpVersion) => {
17+
let php: PHP;
18+
let events: PHPSendmailSpawnedEvent[];
19+
20+
beforeEach(async () => {
21+
events = [];
22+
php = new PHP(await loadNodeRuntime(phpVersion as any));
23+
php.setCommandSpawnHandler('sendmail', sendmailSpawnHandler(php));
24+
php.addEventListener('sendmail.spawned', (event) => {
25+
events.push(event as PHPSendmailSpawnedEvent);
26+
});
27+
await setPhpIniEntries(php, {
28+
disable_functions: '',
29+
});
30+
}, 30_000);
31+
32+
afterEach(() => {
33+
php?.exit();
34+
});
35+
36+
it('captures message sent to sendmail stdin via proc_open()', async () => {
37+
const result = await php.run({
38+
code: `<?php
39+
error_reporting(E_ALL);
40+
$email = "From: sender@test.com\\r\\n"
41+
. "To: recipient@test.com\\r\\n"
42+
. "Subject: Raw proc_open\\r\\n"
43+
. "\\r\\n"
44+
. "Raw proc_open body.";
45+
$proc = proc_open(
46+
'/usr/sbin/sendmail -t -i -fsender@test.com',
47+
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
48+
$pipes
49+
);
50+
if (!is_resource($proc)) {
51+
echo 'PROC_OPEN_FAILED';
52+
exit;
53+
}
54+
fwrite($pipes[0], $email);
55+
fclose($pipes[0]);
56+
$exit = proc_close($proc);
57+
echo $exit === 0 ? 'SENT' : 'EXIT_' . $exit;
58+
`,
59+
});
60+
61+
expect(result.text).toBe('SENT');
62+
expect(events).toHaveLength(1);
63+
const message = await decodeStream(events[0].stdin);
64+
expect(message).toContain('Subject: Raw proc_open');
65+
expect(message).toContain('Raw proc_open body.');
66+
});
67+
68+
it('waits for delayed sendmail stdin bytes', async () => {
69+
const result = await php.run({
70+
code: `<?php
71+
error_reporting(E_ALL);
72+
$proc = proc_open(
73+
'/usr/sbin/sendmail -t',
74+
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
75+
$pipes
76+
);
77+
if (!is_resource($proc)) {
78+
echo 'PROC_OPEN_FAILED';
79+
exit;
80+
}
81+
usleep(500000);
82+
fwrite($pipes[0], "Subject: Delayed proc_open\r\n\r\nDelayed body.");
83+
fclose($pipes[0]);
84+
$exit = proc_close($proc);
85+
echo $exit === 0 ? 'SENT' : 'EXIT_' . $exit;
86+
`,
87+
});
88+
89+
expect(result.text).toBe('SENT');
90+
expect(events).toHaveLength(1);
91+
const message = await decodeStream(events[0].stdin);
92+
expect(message).toContain('Subject: Delayed proc_open');
93+
expect(message).toContain('Delayed body.');
94+
});
95+
96+
it('exits 75 when sendmail receives no stdin bytes', async () => {
97+
const result = await php.run({
98+
code: `<?php
99+
error_reporting(E_ALL);
100+
$proc = proc_open(
101+
'/usr/sbin/sendmail -t',
102+
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
103+
$pipes
104+
);
105+
if (!is_resource($proc)) {
106+
echo 'PROC_OPEN_FAILED';
107+
exit;
108+
}
109+
fclose($pipes[0]);
110+
$stderr = stream_get_contents($pipes[2]);
111+
$exit = proc_close($proc);
112+
echo json_encode(['exit' => $exit, 'stderr' => $stderr]);
113+
`,
114+
});
115+
116+
const data = result.json;
117+
expect(data).toEqual({
118+
exit: 75,
119+
stderr: 'sendmail: fatal: No message bytes received over stdin\n',
120+
});
121+
expect(events).toHaveLength(0);
122+
});
123+
124+
it('rejects messages larger than the fixed capture limit', async () => {
125+
const result = await php.run({
126+
code: `<?php
127+
error_reporting(E_ALL);
128+
$proc = proc_open(
129+
'/usr/sbin/sendmail -t',
130+
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
131+
$pipes
132+
);
133+
if (!is_resource($proc)) {
134+
echo 'PROC_OPEN_FAILED';
135+
exit;
136+
}
137+
fwrite($pipes[0], str_repeat('a', ${SENDMAIL_CAPTURE_MAX_SIZE + 1}));
138+
fclose($pipes[0]);
139+
$stderr = stream_get_contents($pipes[2]);
140+
$exit = proc_close($proc);
141+
echo json_encode(['exit' => $exit, 'stderr' => $stderr]);
142+
`,
143+
});
144+
145+
const data = result.json;
146+
expect(data).toEqual({
147+
exit: 1,
148+
stderr: `sendmail: Message size exceeds fixed maximum message size (${SENDMAIL_CAPTURE_MAX_SIZE})\n`,
149+
});
150+
expect(events).toHaveLength(1);
151+
await expect(decodeStream(events[0].stdin)).rejects.toThrow(
152+
`sendmail: Message size exceeds fixed maximum message size (${SENDMAIL_CAPTURE_MAX_SIZE})`
153+
);
154+
}, 30_000);
155+
156+
it('should capture email after runtime rotation', async () => {
157+
await php.hotSwapPHPRuntime(await loadNodeRuntime(phpVersion as any));
158+
await setPhpIniEntries(php, {
159+
disable_functions: '',
160+
});
161+
162+
const result = await php.run({
163+
code: `<?php
164+
error_reporting(E_ALL);
165+
$result = mail(
166+
'recipient@test.com',
167+
'Raw subject',
168+
'Raw body.',
169+
'From: sender@test.com'
170+
);
171+
echo $result ? 'SENT' : 'FAILED';
172+
`,
173+
});
174+
175+
expect(result.text).toBe('SENT');
176+
expect(events).toHaveLength(1);
177+
const message = await decodeStream(events[0].stdin);
178+
expect(message).toContain('Subject: Raw subject');
179+
expect(message).toContain('Raw body.');
180+
});
181+
});
182+
183+
async function decodeStream(stream: ReadableStream<Uint8Array>) {
184+
const reader = stream.getReader();
185+
const decoder = new TextDecoder();
186+
let text = '';
187+
while (true) {
188+
const { value, done } = await reader.read();
189+
if (done) {
190+
return text;
191+
}
192+
text += decoder.decode(value, { stream: true });
193+
}
194+
}

‎packages/php-wasm/universal/src/lib/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ export {
7575
export type { MountHandler, UnmountFunction } from './php';
7676
export { loadPHPRuntime, popLoadedRuntime } from './load-php-runtime';
7777
export { phpEventStdinTransfer } from '@php-wasm/util';
78-
export type { PHPEventWithStdinTransfer } from '@php-wasm/util';
78+
export type {
79+
PHPEventWithStdinTransfer,
80+
PHPSendmailSpawnedEvent,
81+
} from '@php-wasm/util';
7982
export type { Emscripten } from './emscripten-types';
8083
export type {
8184
DataModule,

‎packages/php-wasm/universal/src/lib/universal-php.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PHPSendmailSpawnedEvent } from '@php-wasm/util';
12
import type { Remote } from './comlink-sync';
23
import type { Pooled } from './object-pool-proxy';
34
import type { LimitedPHPApi } from './php-worker';
@@ -50,7 +51,8 @@ export type PHPEvent =
5051
| PHPRequestErrorEvent
5152
| PHPRuntimeInitializedEvent
5253
| PHPRuntimeBeforeExitEvent
53-
| PHPFilesystemWriteEvent;
54+
| PHPFilesystemWriteEvent
55+
| PHPSendmailSpawnedEvent;
5456

5557
/**
5658
* A callback function that handles PHP events.

‎packages/php-wasm/util/src/lib/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export {
1515
export { createSpawnHandler } from './create-spawn-handler';
1616
export { phpEventStdinTransfer } from './php-event';
1717
export type { PHPEventWithStdinTransfer } from './php-event';
18+
export {
19+
sendmailSpawnHandler,
20+
SENDMAIL_CAPTURE_MAX_SIZE,
21+
} from './spawn-handlers/sendmail';
22+
export type { PHPSendmailSpawnedEvent } from './spawn-handlers/sendmail';
1823
export { randomString } from './random-string';
1924
export { randomFilename } from './random-filename';
2025
export { splitShellCommand } from './split-shell-command';
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { createSpawnHandler } from '../create-spawn-handler';
2+
import {
3+
phpEventStdinTransfer,
4+
type PHPEventWithStdinTransfer,
5+
} from '../php-event';
6+
7+
/**
8+
* Limit captured sendmail stdin to 20 MB to match common email size limits.
9+
*
10+
* Gmail limits email attachments to 25 MB https://support.google.com/mail/answer/6584
11+
* Outlook limits email attachments to 20 MB https://support.microsoft.com/outlook/reduce-attachment-size-to-send-large-files-with-outlook
12+
*/
13+
export const SENDMAIL_CAPTURE_MAX_SIZE = 20 * 1024 * 1024;
14+
15+
/**
16+
* Emitted when a sendmail null transport receives the first stdin bytes from
17+
* `mail()`, `popen()`, `proc_open()`, or any other spawn of a `sendmail` binary.
18+
*/
19+
export interface PHPSendmailSpawnedEvent extends PHPEventWithStdinTransfer {
20+
type: 'sendmail.spawned';
21+
}
22+
23+
/**
24+
* Creates a sendmail-compatible null transport for the given PHP instance.
25+
* Every message handed to sendmail – via mail(), proc_open(), etc. – is streamed
26+
* from stdin, dispatched as a `sendmail.spawned` event, and never delivered.
27+
* A listener that wants real delivery must relay the message itself.
28+
*
29+
* The captured payload is exposed as the raw bytes written to sendmail stdin.
30+
*
31+
* This stream does not apply backpressure to PHP. Each listener queues unread
32+
* chunks in JavaScript memory when it reads more slowly than PHP writes. The
33+
* source accepts at most SENDMAIL_CAPTURE_MAX_SIZE bytes. Cancelling the stream
34+
* stops delivery through that stream without terminating the sendmail process.
35+
*/
36+
export function sendmailSpawnHandler(php: {
37+
dispatchEvent: (event: PHPSendmailSpawnedEvent) => void;
38+
}) {
39+
return createSpawnHandler(async function (commandArray, processApi) {
40+
let totalLength = 0;
41+
let dispatched = false;
42+
let exceededMaxSize = false;
43+
let streamCancelled = false;
44+
let controller: ReadableStreamDefaultController<Uint8Array>;
45+
// Construct the stream before registering stdin. Event dispatch is synchronous,
46+
// and a listener may start reading as soon as the first chunk arrives.
47+
const stdinStream = new ReadableStream<Uint8Array>({
48+
start(streamController) {
49+
controller = streamController;
50+
},
51+
cancel() {
52+
// Cancellation only means no reader wants more bytes from this source. Keep
53+
// the null transport alive so PHP can finish writing and receive an exit code.
54+
streamCancelled = true;
55+
},
56+
});
57+
58+
processApi.on('stdin', (data: Uint8Array) => {
59+
if (!dispatched) {
60+
// Do not publish an email until sendmail receives actual message bytes. An
61+
// invocation that closes stdin immediately is a failed send, not an empty email.
62+
dispatched = true;
63+
php.dispatchEvent({
64+
type: 'sendmail.spawned',
65+
stdin: stdinStream,
66+
[phpEventStdinTransfer]: true,
67+
});
68+
}
69+
70+
totalLength += data.length;
71+
if (totalLength > SENDMAIL_CAPTURE_MAX_SIZE) {
72+
// Fail the consumer now, but keep draining stdin. Exiting here would close
73+
// PHP's write end before the spawning code has finished with the process.
74+
exceededMaxSize = true;
75+
try {
76+
controller!.error(
77+
new Error(
78+
`sendmail: Message size exceeds fixed maximum message size (${SENDMAIL_CAPTURE_MAX_SIZE})`
79+
)
80+
);
81+
} catch {
82+
// The consumer may have cancelled the stream.
83+
}
84+
return;
85+
}
86+
if (!streamCancelled) {
87+
// The PHP bridge reuses its input buffer for later chunks. A stream reader
88+
// may retain this one indefinitely, so it must receive an owned copy.
89+
controller!.enqueue(data.slice());
90+
}
91+
});
92+
93+
const stdin = processApi.childProcess
94+
.stdin as typeof processApi.childProcess.stdin & {
95+
writableEnded?: boolean;
96+
writableFinished?: boolean;
97+
};
98+
// proc_open() may write long after the spawn handler starts. Keep sendmail
99+
// alive until descriptor 0 closes or PHP will observe a truncated pipe.
100+
if (!(stdin.ended || stdin.writableEnded || stdin.writableFinished)) {
101+
await new Promise<void>((resolve) => {
102+
// stdin can finish between the outer check and listener registration.
103+
// Recheck here or that race leaves the sendmail process waiting forever.
104+
if (
105+
stdin.ended ||
106+
stdin.writableEnded ||
107+
stdin.writableFinished
108+
) {
109+
resolve();
110+
return;
111+
}
112+
113+
const finish = () => {
114+
resolve();
115+
};
116+
117+
stdin.once('finish', finish);
118+
});
119+
}
120+
121+
if (exceededMaxSize) {
122+
processApi.stderr(
123+
`sendmail: Message size exceeds fixed maximum message size (${SENDMAIL_CAPTURE_MAX_SIZE})\n`
124+
);
125+
processApi.exit(1);
126+
return;
127+
}
128+
129+
if (totalLength === 0) {
130+
// EX_TEMPFAIL is the sendmail-compatible result for an invocation that never
131+
// supplied a message. In particular, do not turn it into a successful no-op.
132+
processApi.stderr(
133+
'sendmail: fatal: No message bytes received over stdin\n'
134+
);
135+
processApi.exit(75);
136+
return;
137+
}
138+
139+
try {
140+
controller!.close();
141+
} catch {
142+
// The consumer may have cancelled the stream.
143+
}
144+
processApi.exit(0);
145+
});
146+
}

0 commit comments

Comments
 (0)