Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions packages/php-wasm/node/src/lib/load-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import { withXdebug } from './xdebug/with-xdebug';
import { joinPaths } from '@php-wasm/util';
import type { Promised } from '@php-wasm/util';
import { dirname } from 'path';
import { withSMTPSink } from '@php-wasm/universal';

export interface PHPLoaderOptions {
emscriptenOptions?: EmscriptenOptions;
followSymlinks?: boolean;
withXdebug?: boolean;
withSMTPSink?: { port: number; onEmail: (m: any) => void };
}

type PHPLoaderOptionsForNode = PHPLoaderOptions & {
Expand Down Expand Up @@ -191,6 +193,26 @@ export async function loadNodeRuntime(

emscriptenOptions = await withICUData(emscriptenOptions);
emscriptenOptions = await withNetworking(emscriptenOptions);
if (options?.withSMTPSink) {
const prevWs = emscriptenOptions.websocket || {};
const prevDecorator = prevWs.decorator as
| ((Base: any) => any)
| undefined;
const smtp = withSMTPSink(options.withSMTPSink);
const smtpDecorator = smtp.websocket?.decorator as (Base: any) => any;
emscriptenOptions = {
...emscriptenOptions,
websocket: {
...prevWs,
decorator: (Base: any) => {
const AfterPrev = prevDecorator
? prevDecorator(Base)
: Base;
return smtpDecorator(AfterPrev);
},
},
};
}

return await loadPHPRuntime(
await getPHPLoaderModule(phpVersion),
Expand Down
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 @@ -81,6 +81,7 @@ export {
export { isExitCode } from './is-exit-code';
export { proxyFileSystem } 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';
118 changes: 118 additions & 0 deletions packages/php-wasm/universal/src/lib/with-smtp-sink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { EmscriptenOptions } from './load-php-runtime';
import { SmtpSink, makeLoopbackPair, type CaughtMessage } from '@php-wasm/util';

export type WithSmtpSinkOptions = {
port: number;
onEmail: (message: CaughtMessage) => void;
};

/**
* Intercepts TCP connections initiated by the Emscripten runtime that target a specific port
* and routes them through an in-process SMTP sink. Works in both Web and Node runtimes because
* it composes the `websocket.decorator` hook used by networking layers.
*/
export function withSMTPSink({
port,
onEmail,
}: WithSmtpSinkOptions): EmscriptenOptions {
return {
websocket: {
decorator: (BaseWebSocketConstructor: any) => {
return class SMTPDecoratedWebSocket extends BaseWebSocketConstructor {
private __smtpIntercept = false;
private __smtpWriter?: WritableStreamDefaultWriter<Uint8Array>;
private __smtpReaderAbort?: () => void;
private __smtpClosed = false;

constructor(url: string, wsOptions?: any) {
// Determine requested remote port from the query string.
let targetPort = -1;
try {
const u = new URL(url);
targetPort = parseInt(
u.searchParams.get('port') || '-1',
10
);
} catch {}

const isIntercept = targetPort === port;
// super(...) must be a root-level statement in derived classes
super(url, wsOptions);
this.__smtpIntercept = isIntercept;
if (!isIntercept) {
return;
}

// Build a loopback duplex and start the SMTP sink server.
const [duplexClient, duplexServer] = makeLoopbackPair();
const sink = new SmtpSink(duplexServer, onEmail);
void sink.start();

// Writer to send client bytes to the sink
this.__smtpWriter = duplexClient.writable.getWriter();

// Pump sink responses back to the websocket consumer
const reader = duplexClient.readable.getReader();
let aborted = false;
this.__smtpReaderAbort = () => {
aborted = true;
try {
reader.releaseLock();
} catch {}
};
(async () => {
try {
while (!aborted) {
const { done, value } = await reader.read();
if (value) {
// Mirror TCPOverFetchWebsocket's shape: { data: Uint8Array }
(this as any).onmessage?.({
data: value,
});
}
if (done) break;
}
} finally {
if (!this.__smtpClosed) {
(this as any).onclose?.({});
this.__smtpClosed = true;
}
}
})();

// Signal open immediately to match WebSocket semantics
(this as any).onopen?.({});
}

// Override send/close to talk to the in-process SMTP server
send(data: ArrayBuffer | Uint8Array | string) {
if (!this.__smtpIntercept)
return super.send(data as any);
if (!this.__smtpWriter) return;
let bytes: Uint8Array;
if (typeof data === 'string') {
bytes = new TextEncoder().encode(data);
} else if (data instanceof ArrayBuffer) {
bytes = new Uint8Array(data);
} else {
bytes = data;
}
void this.__smtpWriter.write(bytes);
}

close(code?: number, reason?: string) {
if (!this.__smtpIntercept)
return super.close(code as any, reason as any);
if (this.__smtpClosed) return;
this.__smtpClosed = true;
try {
this.__smtpReaderAbort?.();
this.__smtpWriter?.close();
} catch {}
(this as any).onclose?.({});
}
};
},
},
};
}
3 changes: 2 additions & 1 deletion packages/php-wasm/universal/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"include": [
"src/**/*.ts",
"../web/src/php-library/parse-worker-startup-options.ts",
"../web/src/lib/api.ts"
"../web/src/lib/api.ts",
"../node/src/test/with-smtp-sink.spec.ts"
],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
3 changes: 2 additions & 1 deletion packages/php-wasm/universal/tsconfig.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts",
"src/lib/php-vars.ts"
"src/lib/php-vars.ts",
"../node/src/test/with-smtp-sink.spec.ts"
]
}
1 change: 1 addition & 0 deletions packages/php-wasm/util/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { randomFilename } from './random-filename';
export { WritablePolyfill, type WritableOptions } from './writable-polyfill';
export { EventEmitterPolyfill } from './event-emitter-polyfill';
export * from './php-vars';
export * from './smtp';

export * from './sprintf';

Expand Down
93 changes: 93 additions & 0 deletions packages/php-wasm/util/src/lib/smtp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import { SmtpSink, makeLoopbackPair, CaughtMessage } from './smtp';

describe('SmtpSink', () => {
it('captures an email and emits email-sent event', async () => {
const [duplexClient, duplexServer] = makeLoopbackPair();
const onMessage = vi.fn();
const sink = new SmtpSink(duplexServer, onMessage);

const emailSent = new Promise<CaughtMessage>((resolve) => {
sink.addEventListener('email-sent', (event) => {
const e = event as CustomEvent<CaughtMessage>;
resolve(e.detail);
});
});

// Start the sink server (do not await; it runs until the stream ends)
void sink.start();

const writer = duplexClient.writable.getWriter();
const reader = duplexClient.readable.getReader();
const decoder = new TextDecoder();

async function readResponse(): Promise<string> {
const { value } = await reader.read();
if (!value) return '';
return decoder.decode(value);
}

// Greeting
const greeting = await readResponse();
expect(greeting).toMatch(/^220 /);

// HELO (may be multi-line: 250-..., ..., 250 <last>)
await writer.write(new TextEncoder().encode('HELO localhost\r\n'));
let helo = await readResponse();
while (/^250-/.test(helo)) {
helo = await readResponse();
}
expect(helo).toMatch(/^250 /);

// MAIL FROM
await writer.write(
new TextEncoder().encode('MAIL FROM: <test@localhost>\r\n')
);
const mailFromResp = await readResponse();
expect(mailFromResp).toMatch(/^250 /);

// RCPT TO
await writer.write(
new TextEncoder().encode('RCPT TO: <test2@localhost>\r\n')
);
const rcptToResp = await readResponse();
expect(rcptToResp).toMatch(/^250 /);

// DATA
await writer.write(new TextEncoder().encode('DATA\r\n'));
const dataResp = await readResponse();
expect(dataResp).toMatch(/^354 /);

// Message content
await writer.write(new TextEncoder().encode('Subject: Test Email\r\n'));
await writer.write(
new TextEncoder().encode('From: test@localhost\r\n')
);
await writer.write(new TextEncoder().encode('To: test2@localhost\r\n'));
await writer.write(new TextEncoder().encode('\r\n'));
await writer.write(
new TextEncoder().encode('This is the email body content.\r\n')
);
await writer.write(new TextEncoder().encode('.\r\n'));
const queuedResp = await readResponse();
expect(queuedResp).toMatch(/^250 /);

// QUIT
await writer.write(new TextEncoder().encode('QUIT\r\n'));
const quitResp = await readResponse();
expect(quitResp).toMatch(/^221 /);

await writer.close();

// Wait for event
const msg = await emailSent;
expect(msg.subject).toBe('Test Email');
expect(msg.from).toContain('test@localhost');
expect(msg.to).toContain('test2@localhost');
expect((msg.text || '').trim()).toBe('This is the email body content.');

expect(onMessage).toHaveBeenCalledTimes(1);
const firstArg = onMessage.mock.calls[0][0] as CaughtMessage;
expect(firstArg.id).toBe(msg.id);
});
});
Loading