From da99fe73d73b1c55bbe1768979a3ac86729b1243 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:58 +0200 Subject: [PATCH 1/7] fix(pool): type the idle timer handle for newer Deno setTimeout returns an object from Deno 2.6 onwards, so the pooled idle timer map no longer accepts the handle and `deno check` fails. The CI pin at v2.5.4 hid this: the check only passes on that exact release. Name the handle as SMTP.TimerHandle where the timer helpers live and use it for the map, which is correct on every runtime. Also stop `deno task check` from running `deno fmt` in write mode. Formatting drift could never fail CI because the task rewrote the files it was supposed to be checking. Use `deno task fmt` to format. --- deno.json | 3 ++- src/index.ts | 2 +- src/smtp/Timeout.ts | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/deno.json b/deno.json index 52c59c4..c7a7053 100644 --- a/deno.json +++ b/deno.json @@ -61,7 +61,8 @@ "exclude": ["tests/**/*.d.ts"] }, "tasks": { - "check": "deno fmt src/ && deno lint src/ && deno check src/", + "fmt": "deno fmt src/ tests/", + "check": "deno fmt --check src/ tests/ && deno lint src/ && deno check src/ tests/", "test": "deno test --allow-net --allow-env tests/" }, "imports": { diff --git a/src/index.ts b/src/index.ts index a407f50..98151f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,7 +62,7 @@ class SmtpClientPool { /** Busy SMTP clients currently in use */ private busyClients = new Set() /** Idle timer per pooled client */ - private idleTimerByClient = new Map() + private idleTimerByClient = new Map() /** Processed message count per pooled client */ private messageCountByClient = new Map() /** Maximum messages before recycle */ diff --git a/src/smtp/Timeout.ts b/src/smtp/Timeout.ts index 4be640a..494991a 100644 --- a/src/smtp/Timeout.ts +++ b/src/smtp/Timeout.ts @@ -1,3 +1,9 @@ +/** + * Host timer handle. + * @description Opaque value returned by the host runtime timer API; numeric on web runtimes and an object on Node and Bun. + */ +export type TimerHandle = ReturnType + /** Default TCP connect and TLS handshake timeout in milliseconds. */ export const defaultConnectionTimeoutMs = 30000 From 5ca7042b1fce8e09e81bdebc8033011af15f138a Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:37:13 +0200 Subject: [PATCH 2/7] refactor(smtp): remove the unreachable SmtpConnection class Nothing reaches it. SmtpClient builds SmtpCommand, SmtpAuth and SmtpMessage, never SmtpConnection, so the class has been dead since the client was reorganised. It is also a worse copy of the code that does run. No read, write or connect timeouts, a single write() call that silently truncates payloads, a reply reader that stops at the first CRLF and so mangles multi-line 250- responses, and a handshake that only does HELO and only tries STARTTLS on port 587. Keeping it means porting all of that to the new transport, or shipping it broken on Node and Bun too. It is exported, so this is a breaking removal. --- src/smtp/Connection.ts | 137 ---------------------------------- src/smtp/index.ts | 1 - tests/smtp/Connection.test.ts | 14 ---- tests/smtp/index.test.ts | 1 - 4 files changed, 153 deletions(-) delete mode 100644 src/smtp/Connection.ts delete mode 100644 tests/smtp/Connection.test.ts diff --git a/src/smtp/Connection.ts b/src/smtp/Connection.ts deleted file mode 100644 index 00cf12f..0000000 --- a/src/smtp/Connection.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type * as Types from '@app/Types.ts' - -/** - * Manage SMTP connection lifecycle. - * @description Opens, upgrades, and closes SMTP socket state. - */ -export class SmtpConnection { - /** - * Create connection manager. - * @description Stores shared connection state reference. - * @param state - Shared SMTP connection state - */ - constructor(private state: Types.SmtpConnectionState) {} - - /** - * Establish SMTP connection. - * @description Connects and optionally upgrades transport to TLS. - * @throws {Error} When connection fails or timeout occurs - */ - async connect(): Promise { - try { - this.state.conn = await Deno.connect({ - hostname: this.state.config.host, - port: this.state.config.port - }) - await this.readResponse() - await this.sendCommand(`HELO ${this.state.config.host}`) - if (!this.state.config.secure && this.state.config.port === 587) { - await this.sendCommand('startTLS') - await this.upgradeToTLS() - } - } catch (error) { - throw new Error( - `SMTP connection failed: ${error instanceof Error ? error.message : String(error)}` - ) - } - } - - /** - * Close SMTP connection. - * @description Sends QUIT and closes active socket. - */ - async disconnect(): Promise { - if (this.state.tlsConn) { - try { - await this.sendCommand('QUIT') - } catch { - // Ignore errors - } - this.state.tlsConn.close() - this.state.tlsConn = null - } else if (this.state.conn) { - try { - await this.sendCommand('QUIT') - } catch { - // Ignore errors - } - this.state.conn.close() - this.state.conn = null - } - } - - /** - * Upgrade connection to TLS. - * @description Starts TLS over existing TCP connection. - * @throws {Error} When no connection exists to upgrade - */ - async upgradeToTLS(): Promise { - if (!this.state.conn) { - throw new Error('No connection to upgrade') - } - this.state.tlsConn = await Deno.startTls(this.state.conn as Deno.TcpConn, { - hostname: this.state.config.host - }) - this.state.conn = null - } - - /** - * Read server response. - * @description Reads response and validates SMTP status class. - * @returns Server response string - * @throws {Error} When connection is closed or server returns error code - */ - private async readResponse(): Promise { - if (!this.state.conn && !this.state.tlsConn) { - throw new Error('Not connected') - } - const decoder = new TextDecoder() - const buffer = new Uint8Array(1024) - const readChunk = async (): Promise => { - if (this.state.tlsConn) { - return await this.state.tlsConn.read(buffer) - } else if (this.state.conn) { - return await this.state.conn.read(buffer) - } else { - throw new Error('Connection closed') - } - } - const readUntilComplete = async (response: string): Promise => { - const n = await readChunk() - if (n === null) { - throw new Error('Connection closed') - } - const newResponse = response + decoder.decode(buffer.subarray(0, n)) - if (newResponse.endsWith('\r\n')) { - return newResponse - } - return await readUntilComplete(newResponse) - } - const response = await readUntilComplete('') - const code = response.substring(0, 3) - if (!response.startsWith('2') && !response.startsWith('3')) { - throw new Error(`SMTP Error ${code}: ${response}`) - } - return response - } - - /** - * Send SMTP command. - * @description Writes command and waits for server reply. - * @param command - SMTP command to send - * @throws {Error} When command times out or server returns error - */ - private async sendCommand(command: string): Promise { - if (!this.state.conn && !this.state.tlsConn) { - throw new Error('Not connected') - } - const encoder = new TextEncoder() - const data = encoder.encode(`${command}\r\n`) - if (this.state.tlsConn) { - await this.state.tlsConn.write(data) - } else if (this.state.conn) { - await this.state.conn.write(data) - } - await this.readResponse() - } -} diff --git a/src/smtp/index.ts b/src/smtp/index.ts index bfa028a..f4ce908 100644 --- a/src/smtp/index.ts +++ b/src/smtp/index.ts @@ -7,6 +7,5 @@ export * from '@smtp/Auth.ts' export * from '@smtp/Calendar.ts' export * from '@smtp/Client.ts' export * from '@smtp/Command.ts' -export * from '@smtp/Connection.ts' export * from '@smtp/Message.ts' export * from '@smtp/Timeout.ts' diff --git a/tests/smtp/Connection.test.ts b/tests/smtp/Connection.test.ts deleted file mode 100644 index bd0982d..0000000 --- a/tests/smtp/Connection.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { assertRejects } from '@std/assert' -import * as SMTP from '@smtp/index.ts' - -Deno.test('SmtpConnection upgradeToTLS rejects without tcp connection', async () => { - const smtpConnection = new SMTP.SmtpConnection({ - conn: null, - tlsConn: null, - config: { - host: 'smtp.ethereal.email', - port: 587 - } - }) - await assertRejects(() => smtpConnection.upgradeToTLS(), Error, 'No connection to upgrade') -}) diff --git a/tests/smtp/index.test.ts b/tests/smtp/index.test.ts index 9e3828c..6350398 100644 --- a/tests/smtp/index.test.ts +++ b/tests/smtp/index.test.ts @@ -7,6 +7,5 @@ Deno.test('smtp barrel exports SMTP classes used by consumers', () => { assertEquals(typeof SMTP.SmtpCalendar, 'function') assertEquals(typeof SMTP.SmtpClient, 'function') assertEquals(typeof SMTP.SmtpCommand, 'function') - assertEquals(typeof SMTP.SmtpConnection, 'function') assertEquals(typeof SMTP.SmtpMessage, 'function') }) From 243f32c6dcdd9cbf66040ddfe9254df847ffb2a4 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:38:40 +0200 Subject: [PATCH 3/7] feat(runtime): add a socket transport for Deno, Node and Bun The SMTP layer talked to Deno.connect and Deno.startTls directly, and SmtpConnectionState exposed Deno.Conn and Deno.TlsConn through the published types, so neither the code nor its type surface could leave Deno. Put an SmtpTransport interface in front of the socket. Deno keeps its native path, so nothing changes for existing JSR consumers. Node and Bun get a node:net and node:tls implementation that buffers incoming chunks and parks one reader, which keeps SmtpCommand's pull-based read(target) working against push-based streams. The implementation is picked on first connect and loaded with a dynamic import, so neither runtime ever loads the other's module. Two behaviour notes: startTls now resolves only once the handshake has completed, so a single withTimeout covers the whole upgrade instead of the old split between Deno.startTls and handshake(). Because the secured socket only exists inside that pending call, both connect and startTls take an AbortSignal; without it a timed-out handshake orphaned the socket. Deno 2.5.4's leak detector caught exactly that. The Node upgrade refuses to start when bytes are already buffered. Plaintext that arrives before the handshake was never asked for, and folding it into the secured session is the classic STARTTLS command injection. SmtpConnectionState now carries one `transport` field instead of `conn` and `tlsConn`. Breaking, hence 0.4.0. --- deno.json | 5 +- src/Types.ts | 8 +- src/index.ts | 6 + src/runtime/DenoTransport.ts | 144 ++++++++++++++++++++ src/runtime/NodeTransport.ts | 252 +++++++++++++++++++++++++++++++++++ src/runtime/Transport.ts | 79 +++++++++++ src/runtime/index.ts | 5 + src/smtp/Client.ts | 33 ++--- src/smtp/Command.ts | 11 +- 9 files changed, 515 insertions(+), 28 deletions(-) create mode 100644 src/runtime/DenoTransport.ts create mode 100644 src/runtime/NodeTransport.ts create mode 100644 src/runtime/Transport.ts create mode 100644 src/runtime/index.ts diff --git a/deno.json b/deno.json index c7a7053..9741d44 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "name": "@neabyte/deno-mailer", - "description": "Lightweight Deno SMTP mailer with flexible configuration and formatting", - "version": "0.3.0", + "description": "Lightweight SMTP mailer for Deno, Node and Bun with flexible configuration and formatting", + "version": "0.4.0", "type": "module", "license": "MIT", "exports": "./src/index.ts", @@ -69,6 +69,7 @@ "@std/assert": "jsr:@std/assert@^1.0.19", "@app/": "./src/", "@smtp/": "./src/smtp/", + "@runtime/": "./src/runtime/", "@utils/": "./src/utils/", "@tests/": "./tests/" }, diff --git a/src/Types.ts b/src/Types.ts index f44a5b4..f3a6b6c 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1,3 +1,5 @@ +import type { SmtpTransport } from '@runtime/Transport.ts' + /** * Calendar event invitation data. * @description Defines calendar payload fields for event invites. @@ -191,10 +193,8 @@ export interface SmtpConnectionConfig { * @description Holds active sockets and SMTP config reference. */ export interface SmtpConnectionState { - /** Raw TCP connection */ - conn: Deno.Conn | null - /** TLS encrypted connection */ - tlsConn: Deno.TlsConn | null + /** Active transport, plain or secured */ + transport: SmtpTransport | null /** Connection configuration */ config: SmtpConnectionConfig } diff --git a/src/index.ts b/src/index.ts index 98151f2..232117e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -179,3 +179,9 @@ export default mailer * @description Exposes shared interfaces and type aliases. */ export type * from '@app/Types.ts' + +/** + * Re-export transport types. + * @description Exposes the socket abstraction that backs every runtime. + */ +export type * from '@runtime/Transport.ts' diff --git a/src/runtime/DenoTransport.ts b/src/runtime/DenoTransport.ts new file mode 100644 index 0000000..68f9585 --- /dev/null +++ b/src/runtime/DenoTransport.ts @@ -0,0 +1,144 @@ +import type { SmtpConnectOptions, SmtpTlsOptions, SmtpTransport } from '@runtime/Transport.ts' + +/** + * Open a plain TCP transport on Deno. + * @description Connects with the native Deno socket API. + * @param options - Target host, port and optional abort signal + * @returns Connected transport + * @throws {Error} When the runtime does not expose Deno sockets + */ +export async function connectDenoTransport(options: SmtpConnectOptions): Promise { + const host = globalThis as unknown as { Deno?: DenoSockets } + if (!host.Deno) { + throw new Error('Deno sockets are unavailable on this runtime') + } + const connection = await host.Deno.connect( + options.signal + ? { hostname: options.hostname, port: options.port, signal: options.signal } + : { hostname: options.hostname, port: options.port } + ) + return new DenoTransport(host.Deno, connection, false) +} + +/** + * Deno socket transport. + * @description Wraps a Deno TCP or TLS connection behind the SMTP transport contract. + */ +class DenoTransport implements SmtpTransport { + /** + * Wrap a Deno connection. + * @description Stores the socket together with the namespace used for TLS upgrades. + * @param deno - Deno socket namespace + * @param connection - Open Deno connection + * @param isSecure - True when the connection already carries TLS + */ + constructor( + private deno: DenoSockets, + private connection: DenoConnection, + readonly isSecure: boolean + ) {} + + /** + * Read bytes from the socket. + * @description Delegates to the native read. + * @param target - Buffer that receives the bytes + * @returns Byte count written into target, or null at end of stream + */ + read(target: Uint8Array): Promise { + return this.connection.read(target) + } + + /** + * Write bytes to the socket. + * @description Delegates to the native write, which may accept a partial payload. + * @param payload - Bytes to send + * @returns Number of bytes accepted by the socket + */ + write(payload: Uint8Array): Promise { + return this.connection.write(payload) + } + + /** + * Upgrade the socket to TLS. + * @description Starts TLS over the plain socket and awaits the handshake. The secured socket + * is closed when the handshake fails or the signal aborts, since the caller cannot reach it + * before this resolves. + * @param options - TLS handshake options + * @returns Secured transport replacing this one + * @throws {Error} When the transport already carries TLS, or the handshake fails or is aborted + */ + async startTls(options: SmtpTlsOptions): Promise { + if (this.isSecure) { + throw new Error('Transport is already secured') + } + const secured = await this.deno.startTls(this.connection, { hostname: options.hostname }) + const closeSecured = (): void => { + try { + secured.close() + } catch { + // Already closed + } + } + if (options.signal?.aborted) { + closeSecured() + throw new Error('TLS handshake was aborted') + } + options.signal?.addEventListener('abort', closeSecured, { once: true }) + try { + await secured.handshake() + } catch (error) { + closeSecured() + throw error + } finally { + options.signal?.removeEventListener('abort', closeSecured) + } + return new DenoTransport(this.deno, secured, true) + } + + /** + * Close the socket. + * @description Ignores failures from an already closed socket. + */ + close(): void { + try { + this.connection.close() + } catch { + // Already closed + } + } +} + +/** + * Deno connection shape used here. + * @description Structural view of `Deno.Conn`, declared locally so the module type-checks + * without Deno's ambient library when it is bundled for npm. + */ +interface DenoConnection { + /** Reads the next bytes into target, or resolves null at end of stream */ + read(target: Uint8Array): Promise + /** Writes bytes and resolves the accepted count */ + write(payload: Uint8Array): Promise + /** Releases the socket */ + close(): void +} + +/** Structural view of `Deno.TlsConn`. */ +interface DenoTlsConnection extends DenoConnection { + /** Resolves once the TLS handshake completes */ + handshake(): Promise +} + +/** Structural view of the Deno socket namespace. */ +interface DenoSockets { + /** Opens a TCP connection */ + connect(options: { + hostname: string + port: number + signal?: AbortSignal + }): Promise + /** Upgrades a plain connection to TLS */ + startTls( + connection: DenoConnection, + options: { hostname: string } + ): Promise +} diff --git a/src/runtime/NodeTransport.ts b/src/runtime/NodeTransport.ts new file mode 100644 index 0000000..37ab10c --- /dev/null +++ b/src/runtime/NodeTransport.ts @@ -0,0 +1,252 @@ +import type { SmtpConnectOptions, SmtpTlsOptions, SmtpTransport } from '@runtime/Transport.ts' +import { connect as connectTcp, isIP, type Socket } from 'node:net' +import { connect as connectTls, type TLSSocket } from 'node:tls' + +/** + * Open a plain TCP transport on Node and Bun. + * @description Connects with `node:net` and resolves once the socket is usable. + * @param options - Target host, port and optional abort signal + * @returns Connected transport + * @throws {Error} When the socket fails or the signal aborts first + */ +export function connectNodeTransport(options: SmtpConnectOptions): Promise { + const { promise, resolve, reject } = Promise.withResolvers() + const socket = connectTcp({ host: options.hostname, port: options.port }) + const settle = (outcome: () => void): void => { + socket.removeListener('connect', onConnect) + socket.removeListener('error', onError) + options.signal?.removeEventListener('abort', onAbort) + outcome() + } + const onConnect = (): void => settle(() => resolve(new NodeTransport(socket, false))) + const onError = (error: Error): void => settle(() => reject(error)) + const onAbort = (): void => { + settle(() => { + socket.destroy() + reject(new Error(`Connection to ${options.hostname}:${options.port} was aborted`)) + }) + } + socket.once('connect', onConnect) + socket.once('error', onError) + if (options.signal?.aborted) { + onAbort() + } else { + options.signal?.addEventListener('abort', onAbort, { once: true }) + } + return promise +} + +/** + * Node socket transport. + * @description Adapts a push-based `node:net` or `node:tls` socket to pull-based reads. + */ +class NodeTransport implements SmtpTransport { + /** Received chunks not yet handed to a reader */ + private pending: Uint8Array[] = [] + /** Bytes of the first pending chunk already consumed */ + private consumed = 0 + /** True once the peer closed its side */ + private ended = false + /** First socket error, replayed to the next reader */ + private failure: Error | null = null + /** Wakes the parked reader when bytes, end or error arrive */ + private notify: (() => void) | null = null + + /** + * Wrap a connected socket. + * @description Attaches the buffering listeners used by `read`. + * @param socket - Connected Node socket + * @param isSecure - True when the socket already carries TLS + */ + constructor(private socket: Socket, readonly isSecure: boolean) { + socket.on('data', this.onData) + socket.on('end', this.onEnd) + socket.on('close', this.onEnd) + socket.on('error', this.onError) + } + + /** + * Read bytes from the socket. + * @description Drains buffered chunks, parking until data, end or error arrives. + * @param target - Buffer that receives the bytes + * @returns Byte count written into target, or null at end of stream + * @throws {Error} When the socket failed + */ + async read(target: Uint8Array): Promise { + while (this.pending.length === 0) { + if (this.failure) { + throw this.failure + } + if (this.ended) { + return null + } + const { promise, resolve } = Promise.withResolvers() + this.notify = resolve + await promise + } + let written = 0 + while (written < target.length && this.pending.length > 0) { + const chunk = this.pending[0] as Uint8Array + const available = chunk.length - this.consumed + const take = Math.min(available, target.length - written) + target.set(chunk.subarray(this.consumed, this.consumed + take), written) + written += take + this.consumed += take + if (this.consumed === chunk.length) { + this.pending.shift() + this.consumed = 0 + } + } + return written + } + + /** + * Write bytes to the socket. + * @description Resolves once the payload has been flushed to the kernel. + * @param payload - Bytes to send + * @returns Number of bytes accepted by the socket + * @throws {Error} When the socket failed + */ + write(payload: Uint8Array): Promise { + const { promise, resolve, reject } = Promise.withResolvers() + if (this.failure) { + reject(this.failure) + return promise + } + this.socket.write(payload, (error) => { + if (error) { + reject(error) + return + } + resolve(payload.length) + }) + return promise + } + + /** + * Upgrade the socket to TLS. + * @description Hands the plain socket to `node:tls` and awaits the handshake. The secured + * socket is destroyed when the handshake fails or the signal aborts, since the caller cannot + * reach it before this resolves. + * @param options - TLS handshake options + * @returns Secured transport replacing this one + * @throws {Error} When the transport is already secured, unread bytes are buffered, + * or the handshake fails or is aborted + */ + startTls(options: SmtpTlsOptions): Promise { + if (this.isSecure) { + throw new Error('Transport is already secured') + } + // Bytes arriving before the upgrade are plaintext the server was not asked for and + // would otherwise be injected into the secured session (STARTTLS command injection). + if (this.pending.length > 0) { + throw new Error('Unexpected data received before the TLS handshake') + } + this.detach() + const { promise, resolve, reject } = Promise.withResolvers() + let secured: TLSSocket + try { + // SNI must carry a host name; Node rejects an IP literal outright, while the + // certificate is still matched against the address through `host`. + secured = connectTls({ + socket: this.socket, + host: options.hostname, + ...(isIP(options.hostname) === 0 ? { servername: options.hostname } : {}) + }) + } catch (error) { + // The socket is already detached, so leaving it open would strand later reads. + this.socket.destroy() + reject(error as Error) + return promise + } + const settle = (outcome: () => void): void => { + secured.removeListener('secureConnect', onSecure) + secured.removeListener('error', onFailure) + options.signal?.removeEventListener('abort', onAbort) + outcome() + } + const onSecure = (): void => settle(() => resolve(new NodeTransport(secured, true))) + const onFailure = (error: Error): void => { + settle(() => { + secured.destroy() + reject(error) + }) + } + const onAbort = (): void => { + settle(() => { + secured.destroy() + reject(new Error('TLS handshake was aborted')) + }) + } + secured.once('secureConnect', onSecure) + secured.once('error', onFailure) + if (options.signal?.aborted) { + onAbort() + } else { + options.signal?.addEventListener('abort', onAbort, { once: true }) + } + return promise + } + + /** + * Close the socket. + * @description Detaches listeners and destroys the socket. + */ + close(): void { + this.detach() + this.socket.destroy() + this.ended = true + this.wake() + } + + /** + * Stop consuming socket events. + * @description Removes the buffering listeners so another owner can take the socket. + */ + private detach(): void { + this.socket.removeListener('data', this.onData) + this.socket.removeListener('end', this.onEnd) + this.socket.removeListener('close', this.onEnd) + this.socket.removeListener('error', this.onError) + } + + /** + * Buffer an inbound chunk. + * @description Queues the bytes and wakes a parked reader. + * @param chunk - Bytes delivered by the socket + */ + private onData = (chunk: Uint8Array): void => { + this.pending.push(chunk) + this.wake() + } + + /** + * Record end of stream. + * @description Marks the transport drained and wakes a parked reader. + */ + private onEnd = (): void => { + this.ended = true + this.wake() + } + + /** + * Record a socket failure. + * @description Stores the first error and wakes a parked reader. + * @param error - Error reported by the socket + */ + private onError = (error: Error): void => { + this.failure ??= error + this.ended = true + this.wake() + } + + /** + * Wake the parked reader. + * @description Resolves and clears the pending read notification. + */ + private wake(): void { + const notify = this.notify + this.notify = null + notify?.() + } +} diff --git a/src/runtime/Transport.ts b/src/runtime/Transport.ts new file mode 100644 index 0000000..456e9cd --- /dev/null +++ b/src/runtime/Transport.ts @@ -0,0 +1,79 @@ +/** + * Byte transport used by the SMTP layer. + * @description Duplex socket abstraction implemented once per host runtime. + */ +export interface SmtpTransport { + /** True once the transport carries TLS traffic */ + readonly isSecure: boolean + /** + * Read bytes from the peer. + * @description Fills target with the next available bytes. + * @param target - Buffer that receives the bytes + * @returns Byte count written into target, or null at end of stream + */ + read(target: Uint8Array): Promise + /** + * Write bytes to the peer. + * @description Hands payload to the socket and reports accepted bytes. + * @param payload - Bytes to send + * @returns Number of bytes accepted by the socket + */ + write(payload: Uint8Array): Promise + /** + * Upgrade the transport to TLS. + * @description Resolves only after the TLS handshake completes. + * @param options - TLS handshake options + * @returns Secured transport replacing this one + */ + startTls(options: SmtpTlsOptions): Promise + /** + * Close the transport. + * @description Releases the socket; repeat calls are ignored. + */ + close(): void +} + +/** Options for opening a plain TCP transport. */ +export interface SmtpConnectOptions { + /** Target host name or address */ + hostname: string + /** Target TCP port */ + port: number + /** Optional signal that aborts a pending connect */ + signal?: AbortSignal +} + +/** Options for upgrading a transport to TLS. */ +export interface SmtpTlsOptions { + /** Server name used for certificate verification and SNI */ + hostname: string + /** Optional signal that closes the socket when the handshake is abandoned */ + signal?: AbortSignal +} + +/** + * Open a plain TCP transport. + * @description Selects the Deno or Node implementation for the host runtime. + * @param options - Target host, port and optional abort signal + * @returns Connected transport + * @throws {Error} When the socket cannot be opened + */ +export async function connectTransport(options: SmtpConnectOptions): Promise { + if (!cachedConnector) { + const host = globalThis as unknown as { Deno?: { connect?: unknown } } + // Runtime-selected module: each transport imports APIs the other runtime lacks, + // so neither may be reached by a static import. + if (typeof host.Deno?.connect === 'function') { + cachedConnector = (await import('@runtime/DenoTransport.ts')).connectDenoTransport + } else { + cachedConnector = (await import('@runtime/NodeTransport.ts')).connectNodeTransport + } + } + return await cachedConnector(options) +} + +/** Opens a plain TCP transport for one specific runtime. */ +type TransportConnector = (options: SmtpConnectOptions) => Promise + +/** Connector for the detected runtime, resolved on first connect. */ +let cachedConnector: TransportConnector | null = null diff --git a/src/runtime/index.ts b/src/runtime/index.ts new file mode 100644 index 0000000..2a12022 --- /dev/null +++ b/src/runtime/index.ts @@ -0,0 +1,5 @@ +/** + * Central export for the runtime layer. + * @description Transport contract and the connector that selects a host implementation. + */ +export * from '@runtime/Transport.ts' diff --git a/src/smtp/Client.ts b/src/smtp/Client.ts index e51b0df..e321223 100644 --- a/src/smtp/Client.ts +++ b/src/smtp/Client.ts @@ -1,3 +1,4 @@ +import * as Runtime from '@runtime/index.ts' import type * as Types from '@app/Types.ts' import * as SMTP from '@smtp/index.ts' import * as Utils from '@utils/index.ts' @@ -26,8 +27,7 @@ export class SmtpClient { constructor(config: Types.SmtpConnectionConfig) { this.config = config this.connectionState = { - conn: null, - tlsConn: null, + transport: null, config: this.config } this.commands = new SMTP.SmtpCommand(this.connectionState) @@ -91,7 +91,7 @@ export class SmtpClient { * @returns True when connection is active */ get isConnected(): boolean { - return Boolean(this.connectionState.conn || this.connectionState.tlsConn) + return Boolean(this.connectionState.transport) } /** @@ -265,19 +265,19 @@ export class SmtpClient { private async openTcpConnection(): Promise { const connectionTimeoutMs = this.config.connectionTimeoutMs ?? SMTP.defaultConnectionTimeoutMs const connectAbort = new AbortController() - const pendingConn = Deno.connect({ + const pendingTransport = Runtime.connectTransport({ hostname: this.config.host, port: this.config.port, signal: connectAbort.signal }) - this.connectionState.conn = await SMTP.withTimeout( - pendingConn, + this.connectionState.transport = await SMTP.withTimeout( + pendingTransport, connectionTimeoutMs, `Connection to ${this.config.host}:${this.config.port} timed out after ${connectionTimeoutMs} ms`, () => { connectAbort.abort() // Runtimes that ignore the signal may still connect; close that socket. - pendingConn.then((lateConn) => lateConn.close(), () => {}) + pendingTransport.then((lateTransport) => lateTransport.close(), () => {}) } ) } @@ -289,20 +289,21 @@ export class SmtpClient { * @throws {SmtpTimeoutError} When TLS handshake exceeds connection timeout */ private async upgradeToTLS(): Promise { - if (!this.connectionState.conn) { + const plainTransport = this.connectionState.transport + if (!plainTransport) { throw new Error('No connection to upgrade') } const connectionTimeoutMs = this.config.connectionTimeoutMs ?? SMTP.defaultConnectionTimeoutMs - const tlsConn = await Deno.startTls(this.connectionState.conn as Deno.TcpConn, { - hostname: this.config.host - }) - this.connectionState.conn = null - this.connectionState.tlsConn = tlsConn - await SMTP.withTimeout( - tlsConn.handshake(), + const handshakeAbort = new AbortController() + this.connectionState.transport = await SMTP.withTimeout( + plainTransport.startTls({ hostname: this.config.host, signal: handshakeAbort.signal }), connectionTimeoutMs, `TLS handshake with ${this.config.host}:${this.config.port} timed out after ${connectionTimeoutMs} ms`, - () => this.commands.close() + () => { + // The secured socket only exists inside the pending upgrade; the signal closes it. + handshakeAbort.abort() + this.commands.close() + } ) } } diff --git a/src/smtp/Command.ts b/src/smtp/Command.ts index 594fbc6..0c642ab 100644 --- a/src/smtp/Command.ts +++ b/src/smtp/Command.ts @@ -18,9 +18,8 @@ export class SmtpCommand { * @description Closes TLS or TCP socket and clears shared connection state. */ close(): void { - const transport = this.state.tlsConn ?? this.state.conn - this.state.tlsConn = null - this.state.conn = null + const transport = this.state.transport + this.state.transport = null try { transport?.close() } catch { @@ -36,14 +35,14 @@ export class SmtpCommand { * @throws {SmtpTimeoutError} When server sends nothing within socket timeout */ async readResponse(): Promise { - if (!this.state.conn && !this.state.tlsConn) { + if (!this.state.transport) { throw new Error('Not connected') } const decoder = new TextDecoder() const buffer = new Uint8Array(1024) const socketTimeoutMs = this.state.config.socketTimeoutMs ?? defaultSocketTimeoutMs const readChunk = async (): Promise => { - const transport = this.state.tlsConn ?? this.state.conn + const transport = this.state.transport if (!transport) { throw new Error('Connection closed') } @@ -114,7 +113,7 @@ export class SmtpCommand { const socketTimeoutMs = this.state.config.socketTimeoutMs ?? defaultSocketTimeoutMs let bytesWritten = 0 while (bytesWritten < payload.length) { - const transport = this.state.tlsConn ?? this.state.conn + const transport = this.state.transport if (!transport) { throw new Error('Not connected') } From 21d593da3c551ced824d99dd4ea32972505530c3 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:38:46 +0200 Subject: [PATCH 4/7] test(runtime): cover the Node and Bun socket transport Deno runs the Deno transport, so the Node one would otherwise ship untested. Deno's node:net support lets the suite drive it directly: a full scripted SMTP session with a 56 KiB DATA payload, reads split across a 4-byte buffer with a null at end of stream, an aborted connect, the refusal to upgrade with buffered plaintext, and an abandoned handshake. The handshake test targets 127.0.0.1 on purpose. node:tls rejects an IP offered as an SNI name, so that case has to stay covered. --- tests/runtime/NodeTransport.test.ts | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/runtime/NodeTransport.test.ts diff --git a/tests/runtime/NodeTransport.test.ts b/tests/runtime/NodeTransport.test.ts new file mode 100644 index 0000000..83726bd --- /dev/null +++ b/tests/runtime/NodeTransport.test.ts @@ -0,0 +1,126 @@ +import { assert, assertEquals } from '@std/assert' +import type * as Types from '@app/Types.ts' +import { connectNodeTransport } from '@runtime/NodeTransport.ts' +import { SmtpCommand } from '@smtp/Command.ts' +import { startSmtpServer } from '@tests/helpers/SmtpServer.ts' + +Deno.test('node transport completes a scripted SMTP session', async () => { + const server = startSmtpServer(['none']) + const transport = await connectNodeTransport({ hostname: '127.0.0.1', port: server.port }) + const state: Types.SmtpConnectionState = { + transport, + config: { host: '127.0.0.1', port: server.port } + } + const commands = new SmtpCommand(state) + const greeting = await commands.readResponse() + assert(greeting.startsWith('220'), `unexpected greeting: ${greeting}`) + assert((await commands.sendCommand('EHLO transport.test')).startsWith('250')) + assert((await commands.sendCommand('MAIL FROM:')).startsWith('250')) + assert((await commands.sendCommand('RCPT TO:')).startsWith('250')) + assert((await commands.sendCommand('DATA')).startsWith('354')) + await commands.sendData(`Subject: parity\r\n\r\n${'payload line\r\n'.repeat(4000)}`) + assert((await commands.sendCommand('.')).startsWith('250')) + assert((await commands.sendCommand('QUIT')).startsWith('221')) + commands.close() + const [outcome] = await server.outcomes + assertEquals( + outcome?.dataBytes, + 'Subject: parity\r\n\r\n'.length + 'payload line\r\n'.length * 4000 + ) +}) + +Deno.test('node transport splits reads and reports end of stream', async () => { + const listener = Deno.listen({ hostname: '127.0.0.1', port: 0 }) + const served = (async () => { + const connection = await listener.accept() + await connection.write(new TextEncoder().encode('ABCDEFGHIJ')) + connection.close() + listener.close() + })() + const transport = await connectNodeTransport({ + hostname: '127.0.0.1', + port: listener.addr.port + }) + const target = new Uint8Array(4) + const chunks: string[] = [] + const decoder = new TextDecoder() + while (true) { + const bytesRead = await transport.read(target) + if (bytesRead === null) { + break + } + chunks.push(decoder.decode(target.subarray(0, bytesRead))) + } + transport.close() + await served + assertEquals(chunks.join(''), 'ABCDEFGHIJ') + assert(chunks.every((chunk) => chunk.length <= 4), `oversized read: ${chunks.join('|')}`) +}) + +Deno.test('node transport aborts a pending connect', async () => { + const abort = new AbortController() + abort.abort() + let failure: Error | null = null + try { + await connectNodeTransport({ hostname: '127.0.0.1', port: 1, signal: abort.signal }) + } catch (error) { + failure = error as Error + } + assert(failure, 'expected the aborted connect to reject') + assert(/aborted/i.test(failure.message), `unexpected message: ${failure.message}`) +}) + +Deno.test('node transport refuses a TLS upgrade with buffered plaintext', async () => { + const listener = Deno.listen({ hostname: '127.0.0.1', port: 0 }) + const served = (async () => { + const connection = await listener.accept() + await connection.write(new TextEncoder().encode('220 ready\r\n250 injected\r\n')) + return connection + })() + const transport = await connectNodeTransport({ + hostname: '127.0.0.1', + port: listener.addr.port + }) + const connection = await served + // A zero-length read parks until bytes land, then leaves them buffered. + assertEquals(await transport.read(new Uint8Array(0)), 0) + let failure: Error | null = null + try { + await transport.startTls({ hostname: 'localhost' }) + } catch (error) { + failure = error as Error + } + transport.close() + connection.close() + listener.close() + assert(failure, 'expected the upgrade to reject') + assert( + /before the TLS handshake/.test(failure.message), + `unexpected message: ${failure.message}` + ) +}) + +Deno.test('node transport aborts an unanswered TLS handshake to an IP host', async () => { + const listener = Deno.listen({ hostname: '127.0.0.1', port: 0 }) + const served = listener.accept() + const transport = await connectNodeTransport({ + hostname: '127.0.0.1', + port: listener.addr.port + }) + const connection = await served + const abort = new AbortController() + // An IP host must not be offered as an SNI name; node:tls rejects that outright. + const upgrade = transport.startTls({ hostname: '127.0.0.1', signal: abort.signal }) + // The upgrade registers its abort listener synchronously, so no wait is needed here. + abort.abort() + let failure: Error | null = null + try { + await upgrade + } catch (error) { + failure = error as Error + } + connection.close() + listener.close() + assert(failure, 'expected the abandoned handshake to reject') + assert(/aborted/i.test(failure.message), `unexpected message: ${failure.message}`) +}) From 1f8900c90490081e933a9fa020c3d98a51adbae7 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:38:54 +0200 Subject: [PATCH 5/7] build: bundle the library and smoke test it on Node and Bun Node cannot run src/ directly because of the @app/ style import map aliases and the .ts specifiers, so there was no way to exercise the Node path end to end before publishing. `deno task bundle` emits a Node-runnable ESM file with node: builtins left external, and scripts/smoke.mjs sends a real message through it to a loopback SMTP server: once in plaintext and once over a STARTTLS upgrade. It checks both sides, the returned result and the bytes the server actually received, so a transport that truncates writes or skips the upgrade fails here. The STARTTLS leg needs the throwaway certificate in the trust store and NODE_EXTRA_CA_CERTS is only read at startup, so the script generates the certificate and re-runs itself once. --- deno.json | 9 +- scripts/smoke.mjs | 317 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 scripts/smoke.mjs diff --git a/deno.json b/deno.json index 9741d44..c8490bd 100644 --- a/deno.json +++ b/deno.json @@ -61,9 +61,12 @@ "exclude": ["tests/**/*.d.ts"] }, "tasks": { - "fmt": "deno fmt src/ tests/", - "check": "deno fmt --check src/ tests/ && deno lint src/ && deno check src/ tests/", - "test": "deno test --allow-net --allow-env tests/" + "fmt": "deno fmt src/ tests/ scripts/", + "check": "deno fmt --check src/ tests/ scripts/ && deno lint src/ && deno check src/ tests/", + "test": "deno test --allow-net --allow-env tests/", + "bundle": "deno bundle --platform=deno --packages=external --format=esm src/index.ts -o dist/mailer.mjs", + "smoke:node": "deno task bundle && node scripts/smoke.mjs", + "smoke:bun": "deno task bundle && bun scripts/smoke.mjs" }, "imports": { "@std/assert": "jsr:@std/assert@^1.0.19", diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..dadf791 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,317 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import tls from 'node:tls' +import { fileURLToPath } from 'node:url' + +/** Address used as the envelope sender in the smoke message. */ +const senderAddress = 'sender@example.com' +/** Recipients the smoke message is addressed to. */ +const recipientAddresses = ['first@example.com', 'second@example.com'] +/** Subject line asserted inside the received DATA payload. */ +const messageSubject = 'Smoke Test Subject' +/** Body text asserted inside the received DATA payload. */ +const messageBody = 'Smoke test body line for the bundled library.' +/** Queue identifier echoed by the fake server after the DATA terminator. */ +const queueId = 'SMOKE-1' + +/** + * Report a failed assertion and exit. + * @description Prints the failure to stderr and terminates with status 1. + * @param message - Description of the failed expectation + */ +function fail(message) { + console.error(`smoke failed: ${message}`) + process.exit(1) +} + +/** + * Assert a condition. + * @description Exits with a non-zero status when the condition is falsy. + * @param condition - Value expected to be truthy + * @param message - Description of the failed expectation + */ +function assert(condition, message) { + if (!condition) { + fail(message) + } +} + +/** + * Extract an address from an SMTP command line. + * @description Reads the value between angle brackets, if present. + * @param line - Raw SMTP command line + * @returns Bare email address or an empty string + */ +function extractAddress(line) { + const start = line.indexOf('<') + const end = line.indexOf('>', start + 1) + if (start === -1 || end === -1) { + return '' + } + return line.slice(start + 1, end) +} + +/** + * Start a loopback SMTP server. + * @description Speaks a minimal session, advertising STARTTLS when TLS material is supplied. + * @param credentials - PEM key and certificate enabling STARTTLS, or null for plaintext + * @returns Server handle with port, captured sessions, and a close helper + */ +async function startSmtpServer(credentials) { + const sessions = [] + const sockets = new Set() + const server = net.createServer((socket) => { + const session = { recipients: [], sender: '', data: '', complete: false, secured: false } + sessions.push(session) + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + serveSession(socket, session, credentials, sockets) + socket.write('220 127.0.0.1 ESMTP smoke\r\n') + }) + const { promise, resolve, reject } = Promise.withResolvers() + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + await promise + return { + port: server.address().port, + sessions, + close() { + for (const socket of sockets) { + socket.destroy() + } + server.close() + } + } +} + +/** + * Drive one SMTP session over a socket. + * @description Parses commands and, on STARTTLS, re-attaches itself to the secured socket. + * @param socket - Plain or secured socket carrying the session + * @param session - Mutable record of what the server observed + * @param credentials - PEM key and certificate enabling STARTTLS, or null for plaintext + * @param sockets - Open socket registry used for teardown + */ +function serveSession(socket, session, credentials, sockets) { + let buffer = '' + let inData = false + // No setEncoding: the raw socket must stay in binary mode, otherwise handing it to + // TLSSocket on STARTTLS corrupts the handshake framing. + socket.on('error', () => {}) + const onData = (raw) => { + const chunk = raw.toString('latin1') + buffer += chunk + for (;;) { + if (inData) { + session.data += buffer + buffer = '' + const terminator = session.data.indexOf('\r\n.\r\n') + if (terminator === -1) { + break + } + buffer = session.data.slice(terminator + 5) + session.data = session.data.slice(0, terminator) + session.complete = true + inData = false + socket.write(`250 OK queued as ${queueId}\r\n`) + continue + } + const lineEnd = buffer.indexOf('\r\n') + if (lineEnd === -1) { + break + } + const line = buffer.slice(0, lineEnd) + buffer = buffer.slice(lineEnd + 2) + const spaceIndex = line.indexOf(' ') + const verb = line.slice(0, spaceIndex === -1 ? line.length : spaceIndex).toUpperCase() + if (verb === 'EHLO' || verb === 'HELO') { + const offer = credentials && !session.secured ? '250-STARTTLS\r\n' : '' + socket.write(`250-127.0.0.1 greets you\r\n250-SIZE 10485760\r\n${offer}250 8BITMIME\r\n`) + continue + } + if (verb === 'STARTTLS' && credentials && !session.secured) { + socket.removeListener('data', onData) + socket.write('220 2.0.0 Ready to start TLS\r\n') + session.secured = true + const secured = new tls.TLSSocket(socket, { + isServer: true, + key: credentials.key, + cert: credentials.cert + }) + sockets.add(secured) + secured.on('close', () => sockets.delete(secured)) + serveSession(secured, session, credentials, sockets) + return + } + if (verb === 'MAIL') { + session.sender = extractAddress(line) + socket.write('250 2.1.0 Sender ok\r\n') + continue + } + if (verb === 'RCPT') { + session.recipients.push(extractAddress(line)) + socket.write('250 2.1.5 Recipient ok\r\n') + continue + } + if (verb === 'DATA') { + inData = true + socket.write('354 End data with .\r\n') + continue + } + if (verb === 'QUIT') { + socket.write('221 2.0.0 Bye\r\n') + socket.end() + continue + } + socket.write('502 5.5.1 Command not implemented\r\n') + } + } + socket.on('data', onData) +} + +/** + * Send one message and verify both sides of the exchange. + * @description Runs a full send against a loopback server and asserts the observed session. + * @param mailer - Library entry point under test + * @param credentials - PEM key and certificate enabling STARTTLS, or null for plaintext + * @param label - Scenario name used in failure messages + */ +async function runScenario(mailer, credentials, label) { + const server = await startSmtpServer(credentials) + const guard = setTimeout(() => { + server.close() + fail(`${label}: timed out after 20s waiting for the send to complete`) + }, 20000) + if (typeof guard.unref === 'function') { + guard.unref() + } + let result + try { + const transport = mailer.transporter({ + host: '127.0.0.1', + port: server.port, + secure: false + }) + result = await transport.send({ + from: senderAddress, + to: recipientAddresses, + subject: messageSubject, + text: messageBody + }) + } catch (error) { + server.close() + clearTimeout(guard) + fail(`${label}: send threw: ${(error && error.stack) || error}`) + } + clearTimeout(guard) + const session = server.sessions[0] + server.close() + assert(session !== undefined, `${label}: server accepted no connection`) + assert( + session.secured === Boolean(credentials), + `${label}: expected secured=${Boolean(credentials)}, saw ${session.secured}` + ) + assert(session.complete, `${label}: server never saw the end-of-data terminator`) + assert( + JSON.stringify(result.acceptedRecipients) === JSON.stringify(recipientAddresses), + `${label}: acceptedRecipients mismatch: ${JSON.stringify(result.acceptedRecipients)}` + ) + assert( + JSON.stringify(session.recipients) === JSON.stringify(recipientAddresses), + `${label}: server recipients mismatch: ${JSON.stringify(session.recipients)}` + ) + assert(session.sender === senderAddress, `${label}: server sender mismatch: ${session.sender}`) + assert( + typeof result.messageId === 'string' && result.messageId.length > 0, + `${label}: messageId is not a non-empty string: ${JSON.stringify(result.messageId)}` + ) + assert( + typeof result.response === 'string' && result.response.startsWith('250'), + `${label}: response does not start with 250: ${JSON.stringify(result.response)}` + ) + assert( + session.data.includes(`Subject: ${messageSubject}`), + `${label}: DATA payload is missing the Subject header` + ) + assert(session.data.includes(messageBody), `${label}: DATA payload is missing the body text`) +} + +/** + * Re-run this script with a throwaway trusted certificate. + * @description The STARTTLS leg needs the self-signed certificate in the trust store, and + * `NODE_EXTRA_CA_CERTS` is only read at process start, so the run is forwarded to a child. + * @returns Exit status of the child process + */ +function relaunchWithTrustedCertificate() { + const workDir = mkdtempSync(join(tmpdir(), 'deno-mailer-smoke-')) + const keyPath = join(workDir, 'key.pem') + const certPath = join(workDir, 'cert.pem') + try { + const generated = spawnSync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-nodes', + '-subj', + '/CN=127.0.0.1', + '-addext', + 'subjectAltName=IP:127.0.0.1' + ], { stdio: 'ignore' }) + if (generated.status !== 0) { + fail('openssl is required to generate the smoke certificate') + } + const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], { + stdio: 'inherit', + env: { + ...process.env, + NODE_EXTRA_CA_CERTS: certPath, + SMOKE_TLS_KEY: keyPath, + SMOKE_TLS_CERT: certPath + } + }) + return child.status ?? 1 + } finally { + rmSync(workDir, { recursive: true, force: true }) + } +} + +/** + * Run the cross-runtime smoke test. + * @description Sends a plaintext message and a STARTTLS message through the bundled library. + */ +async function main() { + if (!process.env.SMOKE_TLS_CERT) { + process.exit(relaunchWithTrustedCertificate()) + } + const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun' + const packageUrl = new URL('../dist/mailer.mjs', import.meta.url) + let mailer + try { + const imported = await import(packageUrl.href) + mailer = imported.default ?? imported.mailer + } catch (error) { + fail(`cannot import ${packageUrl.href}: ${error && error.message}`) + } + assert(mailer && typeof mailer.transporter === 'function', 'bundle exposes no transporter') + const { readFileSync } = await import('node:fs') + const credentials = { + key: readFileSync(process.env.SMOKE_TLS_KEY), + cert: readFileSync(process.env.SMOKE_TLS_CERT) + } + await runScenario(mailer, null, 'plaintext') + await runScenario(mailer, credentials, 'starttls') + console.log(`smoke ok (${runtime}): plaintext and starttls`) +} + +await main() From 22dfcf6f64a4736affc5d7799e9a2769d5ed2417 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:39:00 +0200 Subject: [PATCH 6/7] ci: test on current Deno and smoke Node and Bun The single pinned Deno version is what let the timer handle break reach main unnoticed, so the check and test job now runs the pin and the latest v2.x side by side. Adds smoke jobs for Node 24 (current LTS) and Node 22 (the floor the package claims) plus Bun, and refreshes the action pins: checkout v4 to v7 and setup-node v4 to v7. --- .github/workflows/ci.yml | 61 +++++++++++++++++++++++++++++++++-- .github/workflows/publish.yml | 6 ++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69b6471..6268877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,20 +8,75 @@ on: jobs: test: - name: Check and Test + name: Check and Test (deno ${{ matrix.deno-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Pinned baseline plus the moving latest release, so a type or lint + # change in a newer Deno fails here instead of in the Publish job. + deno-version: [v2.5.4, v2.x] + steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Deno uses: denoland/setup-deno@v2 with: - deno-version: v2.5.4 + deno-version: ${{ matrix.deno-version }} - name: Run source checks run: deno task check - name: Run test suite run: deno task test + + smoke-node: + name: Smoke (node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # 24 is the current LTS; 22 is the floor the package claims to support. + node-version: ['22', '24'] + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + + - name: Send a message on Node + run: deno task smoke:node + + smoke-bun: + name: Smoke (bun) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Send a message on Bun + run: deno task smoke:bun diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index faff7f0..3441c6b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,13 +13,13 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: denoland/setup-deno@v2 with: deno-version: v2.5.4 - - name: Run lint and check - run: deno lint src/ && deno check src/ + - name: Run source checks + run: deno task check - name: Publish package run: deno publish From c8a0012c74c0a6e2e68574b3c845604d3ac06cf0 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:39:06 +0200 Subject: [PATCH 7/7] docs: document Deno, Node and Bun support Install instructions for all three runtimes. Node and Bun install from JSR with `jsr add`, which registers the @jsr registry and serves the same source as ESM with type declarations, so the import specifier does not change. Also drops the Deno.env call from the DKIM example, since it is the one snippet that would not run as written on Node or Bun. --- CHANGELOG.md | 17 ++++++++++++++++- README.md | 29 +++++++++++++++++++++++++---- USAGE.md | 29 ++++++++++++++++++++++------- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe8086..6d5b4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,13 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.4.0] - 2026-10-12 ### Added +- Added Node and Bun support: the package now runs on Deno, Node `>=22` and Bun `>=1.1`. Node and Bun install it from JSR with `npx jsr add @neabyte/deno-mailer`, which serves the same source as ESM with type declarations +- Added `SmtpTransport` runtime abstraction with a native Deno implementation and a `node:net`/`node:tls` implementation selected at connect time +- Added `deno task bundle`, `deno task smoke:node` and `deno task smoke:bun`, which send a message through a bundled build against a loopback SMTP server on each runtime - Added `connectionTimeoutMs` SMTP config option (default `30000`) bounding TCP connect and each TLS handshake - Added `socketTimeoutMs` SMTP config option (default `60000`) bounding each server reply wait and socket write - Added config validation for timeout options (integer between `1` and `2147483647`) @@ -16,14 +19,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Changed +- **Breaking:** `SmtpConnectionState` now carries a single `transport: SmtpTransport | null` field instead of `conn: Deno.Conn | null` and `tlsConn: Deno.TlsConn | null`, removing `Deno.*` from the published type surface +- Updated the TLS upgrade to resolve only after the handshake completes, so one timeout covers the whole upgrade on every runtime - Updated SMTP client to close its socket on timeout so pooled clients reconnect instead of reusing a dead connection - Updated RCPT handling to fail the send on timeout instead of counting the recipient as rejected - Updated implicit TLS (`secure: true`) to connect over TCP then start TLS, so connect and handshake are bounded separately - Updated `SmtpAuth` to share `SmtpCommand` wire I/O instead of a duplicate reader and writer +- Updated CI to run the check and test tasks against both the pinned Deno release and the latest `v2.x` +- Updated `deno task check` to verify formatting instead of rewriting it; use `deno task fmt` to format + +### Removed + +- **Breaking:** Removed the exported `SmtpConnection` class. It was unreachable from `SmtpClient` and reimplemented the transport without read, write or connect timeouts, without partial-write handling, and without multi-line reply parsing ### Fixed +- Fixed `deno check` failing on Deno `>=2.6`, where the pooled idle timer handle is an object rather than a number - Fixed SMTP writes ignoring partial socket writes, which truncated messages larger than one write (64 KiB over TLS) and left `send()` waiting indefinitely +- Fixed the Node and Bun TLS upgrade rejecting data buffered before the handshake, which would otherwise allow STARTTLS command injection +- Fixed the secured socket leaking when a TLS handshake exceeded `connectionTimeoutMs`; the abandoned handshake is now aborted and its socket closed +- Fixed TLS upgrades to an SMTP host given as an IP address, which `node:tls` rejects when the address is offered as an SNI name ## [0.3.0] - 2026-03-25 diff --git a/README.md b/README.md index 9975963..5243fed 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # Deno Mailer -Lightweight Deno SMTP mailer with flexible configuration and formatting. +Lightweight SMTP mailer for Deno, Node and Bun, with flexible configuration and formatting. -[![Deno](https://img.shields.io/badge/deno-%3E%3D2.x-000000?logo=deno&logoColor=white)](https://deno.com) [![JSR](https://jsr.io/badges/@neabyte/deno-mailer)](https://jsr.io/@neabyte/deno-mailer) [![CI](https://github.com/NeaByteLab/Deno-Mailer/actions/workflows/ci.yml/badge.svg)](https://github.com/NeaByteLab/Deno-Mailer/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Deno](https://img.shields.io/badge/deno-%3E%3D2.x-000000?logo=deno&logoColor=white)](https://deno.com) [![Node](https://img.shields.io/badge/node-%3E%3D22-5FA04E?logo=nodedotjs&logoColor=white)](https://nodejs.org) [![Bun](https://img.shields.io/badge/bun-%3E%3D1.1-FBF0DF?logo=bun&logoColor=black)](https://bun.sh) [![JSR](https://jsr.io/badges/@neabyte/deno-mailer)](https://jsr.io/@neabyte/deno-mailer) [![CI](https://github.com/NeaByteLab/Deno-Mailer/actions/workflows/ci.yml/badge.svg)](https://github.com/NeaByteLab/Deno-Mailer/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -20,15 +20,30 @@ Lightweight Deno SMTP mailer with flexible configuration and formatting. - **Transfer encodings**: base64, 7bit, quoted-printable for attachment content - **Calendar invitations**: generates ICS calendar payload for meeting invites - **Structured send result**: returns message id, envelope, accepted, rejected, and response -- **Zero external runtime deps**: built with Deno native capabilities +- **Runs on Deno, Node and Bun**: one source, native `Deno.connect` on Deno and `node:net`/`node:tls` elsewhere +- **Zero external runtime deps**: built on runtime primitives only ## Installation ```bash -# Install package from JSR. +# Deno, from JSR. deno add jsr:@neabyte/deno-mailer ``` +```bash +# Node. +npx jsr add @neabyte/deno-mailer +``` + +```bash +# Bun. +bunx jsr add @neabyte/deno-mailer +``` + +Requires Deno `>=2.x`, Node `>=22` or Bun `>=1.1`. On Node and Bun, `jsr add` registers the +`@jsr` registry in your project and installs the same source as ESM with type declarations, +so the import specifier stays `@neabyte/deno-mailer`. + ## Quick Start ```ts @@ -65,6 +80,12 @@ console.log(result.messageId) deno task check ``` +```bash +# Bundle the library and send a message through it on Node and on Bun. +deno task smoke:node +deno task smoke:bun +``` + ## Test ```bash diff --git a/USAGE.md b/USAGE.md index c2c839a..4c4c0fd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -41,21 +41,35 @@ This document explains the full **Deno Mailer** usage flow, from SMTP configurat ## Installation ```bash -# Install package from JSR. +# Deno. deno add jsr:@neabyte/deno-mailer ``` -Import the mailer from the same specifier your project uses for JSR (after `deno add`, the import map entry is usually `@neabyte/deno-mailer`): +```bash +# Node. +npx jsr add @neabyte/deno-mailer +``` + +```bash +# Bun. +bunx jsr add @neabyte/deno-mailer +``` + +Requires Deno `>=2.x`, Node `>=22` or Bun `>=1.1`. On Node and Bun, `jsr add` registers the +`@jsr` registry in your project and installs the library as ESM with type declarations, so the +import specifier is the same on every runtime. ```ts -import { mailer } from 'jsr:@neabyte/deno-mailer' -// or: import { mailer } from '@neabyte/deno-mailer' +import { mailer } from '@neabyte/deno-mailer' +// Deno also accepts the fully qualified form: +// import { mailer } from 'jsr:@neabyte/deno-mailer' ``` -Types ship with the package. Use `import type { EmailMessage, SmtpConnectionConfig, SmtpSendResult } from 'jsr:@neabyte/deno-mailer'` when you only need types. +Types ship with the package. Use `import type { EmailMessage, SmtpConnectionConfig, SmtpSendResult } from '@neabyte/deno-mailer'` when you only need types. > [!TIP] -> After `deno add`, prefer the import map key (often `@neabyte/deno-mailer`) so paths stay short and consistent. +> The library opens sockets with `Deno.connect` on Deno and with `node:net` on Node and Bun. On +> Deno this needs `--allow-net`; no permission flags apply on Node or Bun. ## Configuration @@ -119,7 +133,8 @@ const transporter = mailer.transporter({ dkim: { domainName: 'example.com', keySelector: 'mail', - privateKey: Deno.env.get('DKIM_PRIVATE_KEY') || '' + // Deno: Deno.env.get('DKIM_PRIVATE_KEY'); Node and Bun: process.env.DKIM_PRIVATE_KEY + privateKey: readDkimPrivateKey() } }) ```