From 784be7b3f0bfa8a8f1cb11b69ea95ec299ed651f Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:27:21 +0200 Subject: [PATCH 01/14] feat(types): add connectionTimeoutMs and socketTimeoutMs options --- src/Types.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Types.ts b/src/Types.ts index 8ce8172..f44a5b4 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -165,7 +165,7 @@ export interface SmtpPasswordAuthCredential extends SmtpAuthBase<'password'> { /** * SMTP connection configuration. - * @description Defines host port auth DKIM and pooling options. + * @description Defines host port TLS timeouts auth DKIM and pooling options. */ export interface SmtpConnectionConfig { /** SMTP server hostname */ @@ -174,6 +174,10 @@ export interface SmtpConnectionConfig { port: number /** Whether to use secure TLS connection */ secure?: boolean + /** TCP connect and TLS handshake timeout in milliseconds */ + connectionTimeoutMs?: number + /** Inactivity timeout for each SMTP socket read or write in milliseconds */ + socketTimeoutMs?: number /** Optional authentication credentials */ auth?: SmtpAuthCredential /** Optional DKIM signing configuration */ From 4d25911ea4d3b2d7d45964d234cdbf80b6ff424b Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:27:21 +0200 Subject: [PATCH 02/14] feat(config): validate connect and socket timeout values --- src/utils/Config.ts | 19 +++++++++++++++++++ tests/utils/Config.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/utils/Config.ts b/src/utils/Config.ts index 26705b1..419bd23 100644 --- a/src/utils/Config.ts +++ b/src/utils/Config.ts @@ -16,6 +16,8 @@ export function validateSmtpConfig(config: Types.SmtpConnectionConfig): void { validateSmtpPool(config.pool ?? undefined) validateSmtpPort(config.port) validateSmtpSecure(config.secure ?? false) + validateSmtpTimeout('connectionTimeoutMs', config.connectionTimeoutMs) + validateSmtpTimeout('socketTimeoutMs', config.socketTimeoutMs) } /** @@ -161,3 +163,20 @@ function validateSmtpSecure(secure: boolean): void { throw new Error('SMTP secure option must be a boolean') } } + +/** + * Validates SMTP timeout configuration. + * @description Ensures timeout is an integer within the setTimeout delay range. + * @param optionName - Config option name used in error message + * @param timeoutMs - Timeout in milliseconds to validate + * @throws {Error} When timeout validation fails + */ +function validateSmtpTimeout(optionName: string, timeoutMs: number | undefined): void { + if (timeoutMs === undefined) { + return + } + // setTimeout clamps delays above 2^31 - 1 ms to 1 ms. + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) { + throw new Error(`SMTP ${optionName} must be integer between 1 and 2147483647`) + } +} diff --git a/tests/utils/Config.test.ts b/tests/utils/Config.test.ts index ec82f19..c4c83b0 100644 --- a/tests/utils/Config.test.ts +++ b/tests/utils/Config.test.ts @@ -40,6 +40,15 @@ Deno.test('validateSmtpConfig accepts SMTP config with dkim and pool settings', }) }) +Deno.test('validateSmtpConfig accepts SMTP timeouts at 1 and 2147483647 ms', () => { + Utils.validateSmtpConfig({ + host: 'smtp.ethereal.email', + port: 587, + connectionTimeoutMs: 1, + socketTimeoutMs: 2147483647 + }) +}) + Deno.test('validateSmtpConfig accepts valid SMTP config', () => { Utils.validateSmtpConfig({ host: 'smtp.ethereal.email', @@ -69,6 +78,19 @@ Deno.test('validateSmtpConfig rejects auth without type discriminator', () => { ) }) +Deno.test('validateSmtpConfig rejects connectionTimeoutMs of zero', () => { + assertThrows( + () => + Utils.validateSmtpConfig({ + host: 'smtp.ethereal.email', + port: 587, + connectionTimeoutMs: 0 + }), + Error, + 'connectionTimeoutMs must be integer between 1 and 2147483647' + ) +}) + Deno.test('validateSmtpConfig rejects empty auth user', () => { assertThrows( () => @@ -282,3 +304,16 @@ Deno.test('validateSmtpConfig rejects SMTP host that is only whitespace', () => 'host cannot be empty' ) }) + +Deno.test('validateSmtpConfig rejects socketTimeoutMs above setTimeout limit', () => { + assertThrows( + () => + Utils.validateSmtpConfig({ + host: 'smtp.ethereal.email', + port: 587, + socketTimeoutMs: 2147483648 + }), + Error, + 'socketTimeoutMs must be integer between 1 and 2147483647' + ) +}) From 77f3bfc1d74985ba8e5845e8b81d7c643f659100 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:27:21 +0200 Subject: [PATCH 03/14] feat(smtp): add withTimeout helper and SmtpTimeoutError --- src/smtp/Timeout.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/smtp/index.ts | 3 ++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/smtp/Timeout.ts diff --git a/src/smtp/Timeout.ts b/src/smtp/Timeout.ts new file mode 100644 index 0000000..4be640a --- /dev/null +++ b/src/smtp/Timeout.ts @@ -0,0 +1,42 @@ +/** Default TCP connect and TLS handshake timeout in milliseconds. */ +export const defaultConnectionTimeoutMs = 30000 + +/** Default SMTP socket inactivity timeout in milliseconds. */ +export const defaultSocketTimeoutMs = 60000 + +/** + * SMTP operation timeout. + * @description Raised when connect, handshake, read, or write exceeds its limit. + */ +export class SmtpTimeoutError extends Error { + /** Error name for diagnostics */ + override name = 'SmtpTimeoutError' +} + +/** + * Await operation with deadline. + * @description Rejects once the deadline passes and runs cleanup that cancels the operation. + * @param operation - Pending socket operation + * @param timeoutMs - Deadline in milliseconds + * @param message - Timeout error message + * @param onTimeout - Releases resources held by the operation + * @returns Operation result when it settles before the deadline + * @throws {SmtpTimeoutError} When the deadline passes first + */ +export async function withTimeout( + operation: Promise, + timeoutMs: number, + message: string, + onTimeout: () => void +): Promise { + const { promise: deadline, reject } = Promise.withResolvers() + const timerId = setTimeout(() => { + reject(new SmtpTimeoutError(message)) + onTimeout() + }, timeoutMs) + try { + return await Promise.race([operation, deadline]) + } finally { + clearTimeout(timerId) + } +} diff --git a/src/smtp/index.ts b/src/smtp/index.ts index b6b8cb8..bfa028a 100644 --- a/src/smtp/index.ts +++ b/src/smtp/index.ts @@ -1,6 +1,6 @@ /** * Central export for SMTP layer. - * @description Client auth transport commands addressing MIME calendar. + * @description Client auth transport commands addressing MIME calendar timeouts. */ export * from '@smtp/Address.ts' export * from '@smtp/Auth.ts' @@ -9,3 +9,4 @@ 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' From f1a4cee9047fab6abcc158ea14b0de5a66887da0 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:27:39 +0200 Subject: [PATCH 04/14] fix(smtp): keep writing until the whole payload is sent --- src/smtp/Command.ts | 41 ++++++----- tests/helpers/SmtpServer.ts | 140 ++++++++++++++++++++++++++++++++++++ tests/smtp/Client.test.ts | 26 ++++++- 3 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 tests/helpers/SmtpServer.ts diff --git a/src/smtp/Command.ts b/src/smtp/Command.ts index 348c7f6..a93f78f 100644 --- a/src/smtp/Command.ts +++ b/src/smtp/Command.ts @@ -63,19 +63,10 @@ export class SmtpCommand { * @description Writes command and waits for response. * @param command - SMTP command to send * @returns Server response string - * @throws {Error} When command times out or server returns error + * @throws {Error} When not connected or server returns error */ async sendCommand(command: string): Promise { - if (!this.state.conn && !this.state.tlsConn) { - throw new Error('Not connected') - } - const encoder = new TextEncoder() - const commandPayload = encoder.encode(`${command}\r\n`) - if (this.state.tlsConn) { - await this.state.tlsConn.write(commandPayload) - } else if (this.state.conn) { - await this.state.conn.write(commandPayload) - } + await this.writeAll(new TextEncoder().encode(`${command}\r\n`)) return await this.readResponse() } @@ -83,18 +74,26 @@ export class SmtpCommand { * Send raw SMTP data. * @description Writes payload bytes without reading response. * @param data - Raw data to send - * @throws {Error} When not connected to server or timeout occurs + * @throws {Error} When not connected to server */ async sendData(data: string): Promise { - if (!this.state.conn && !this.state.tlsConn) { - throw new Error('Not connected') - } - const encoder = new TextEncoder() - const encoded = encoder.encode(data) - if (this.state.tlsConn) { - await this.state.tlsConn.write(encoded) - } else if (this.state.conn) { - await this.state.conn.write(encoded) + await this.writeAll(new TextEncoder().encode(data)) + } + + /** + * Write full payload to transport. + * @description Repeats partial socket writes until every byte is written. + * @param payload - Bytes to write + * @throws {Error} When not connected to server + */ + private async writeAll(payload: Uint8Array): Promise { + let bytesWritten = 0 + while (bytesWritten < payload.length) { + const transport = this.state.tlsConn ?? this.state.conn + if (!transport) { + throw new Error('Not connected') + } + bytesWritten += await transport.write(payload.subarray(bytesWritten)) } } } diff --git a/tests/helpers/SmtpServer.ts b/tests/helpers/SmtpServer.ts new file mode 100644 index 0000000..848485b --- /dev/null +++ b/tests/helpers/SmtpServer.ts @@ -0,0 +1,140 @@ +/** Protocol point where a scripted session stops answering. */ +export type SmtpStallPoint = 'greeting' | 'rcpt' | 'none' + +/** Observed result of one scripted SMTP session. */ +export interface SmtpSessionOutcome { + /** True when the client closed its socket before the server gave up */ + clientClosed: boolean + /** DATA payload bytes received before the end-of-data marker */ + dataBytes: number +} + +/** Running scripted SMTP server. */ +export interface ScriptedSmtpServer { + /** Loopback port the server listens on */ + port: number + /** Settles after every scripted session has ended */ + outcomes: Promise +} + +/** + * Start scripted loopback SMTP server. + * @description Serves one session per plan entry, in accept order, then stops listening. + * @param plans - Stall point for each accepted connection + * @param holdMs - How long a stalled session waits for the client to hang up + * @returns Server port and session outcomes + */ +export function startSmtpServer(plans: SmtpStallPoint[], holdMs = 3000): ScriptedSmtpServer { + const listener = Deno.listen({ hostname: '127.0.0.1', port: 0 }) + const outcomes = (async () => { + const results: SmtpSessionOutcome[] = [] + try { + for (const plan of plans) { + results.push(await runSession(await listener.accept(), plan, holdMs)) + } + } finally { + listener.close() + } + return results + })() + return { port: listener.addr.port, outcomes } +} + +/** + * Serve one scripted SMTP session. + * @description Answers EHLO MAIL RCPT DATA QUIT unless the plan stalls first. + * @param conn - Accepted client connection + * @param plan - Stall point for this session + * @param holdMs - How long a stall waits for the client to hang up + * @returns Session outcome + */ +async function runSession( + conn: Deno.Conn, + plan: SmtpStallPoint, + holdMs: number +): Promise { + const buffer = new Uint8Array(64 * 1024) + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let pending = '' + let dataBytes = 0 + const readMore = async (): Promise => { + const bytesRead = await conn.read(buffer) + if (bytesRead === null) { + return false + } + pending += decoder.decode(buffer.subarray(0, bytesRead)) + return true + } + const readLine = async (): Promise => { + while (!pending.includes('\r\n')) { + if (!(await readMore())) { + return null + } + } + const lineEnd = pending.indexOf('\r\n') + const line = pending.slice(0, lineEnd) + pending = pending.slice(lineEnd + 2) + return line + } + const readData = async (): Promise => { + let searchFrom = 0 + while (true) { + const markerIndex = pending.indexOf('\r\n.\r\n', searchFrom) + if (markerIndex >= 0) { + pending = pending.slice(markerIndex + 5) + return markerIndex + 2 + } + searchFrom = Math.max(0, pending.length - 4) + if (!(await readMore())) { + return pending.length + } + } + } + const reply = async (text: string): Promise => { + await conn.write(encoder.encode(`${text}\r\n`)) + } + const awaitHangup = async (): Promise => { + const { promise: holdExpired, resolve } = Promise.withResolvers() + const holdTimer = setTimeout(() => resolve(false), holdMs) + const hangup = (async () => { + while (await readMore()) { + // Discard anything the client sends while the server stalls. + } + return true + })() + try { + return await Promise.race([hangup, holdExpired]) + } finally { + clearTimeout(holdTimer) + } + } + try { + if (plan === 'greeting') { + return { clientClosed: await awaitHangup(), dataBytes } + } + await reply('220 scripted ESMTP') + while (true) { + const line = await readLine() + if (line === null) { + return { clientClosed: true, dataBytes } + } + const verb = line.slice(0, 4).toUpperCase() + if (verb === 'RCPT' && plan === 'rcpt') { + return { clientClosed: await awaitHangup(), dataBytes } + } + if (verb === 'DATA') { + await reply('354 Send data') + dataBytes = await readData() + await reply('250 OK queued') + } else if (verb === 'QUIT') { + await reply('221 Bye') + return { clientClosed: await awaitHangup(), dataBytes } + } else { + await reply(['EHLO', 'MAIL', 'RCPT'].includes(verb) ? '250 OK' : '502 Unsupported') + } + } + } finally { + conn.close() + } +} diff --git a/tests/smtp/Client.test.ts b/tests/smtp/Client.test.ts index 10dc295..1caf940 100644 --- a/tests/smtp/Client.test.ts +++ b/tests/smtp/Client.test.ts @@ -1,5 +1,29 @@ -import { assertRejects } from '@std/assert' +import { assert, assertEquals, assertRejects } from '@std/assert' import * as SMTP from '@smtp/index.ts' +import { startSmtpServer } from '@tests/helpers/SmtpServer.ts' + +const testMessage = { + from: 'sender@example.com', + to: 'receiver@example.com', + subject: 'Test', + text: 'Hello' +} + +Deno.test('SmtpClient sendMessage delivers DATA larger than one socket write', async () => { + const server = startSmtpServer(['none']) + const smtpClient = new SMTP.SmtpClient({ + host: '127.0.0.1', + port: server.port, + socketTimeoutMs: 5000 + }) + const text = `${'x'.repeat(76)}\n`.repeat(128 * 1024) + await smtpClient.connect() + const sendResult = await smtpClient.sendMessage({ ...testMessage, text }) + await smtpClient.disconnect() + const [outcome] = await server.outcomes + assertEquals(sendResult.response, '250 OK queued\r\n') + assert((outcome?.dataBytes ?? 0) > text.length) +}) Deno.test('SmtpClient sendMessage rejects when not connected to server', async () => { const smtpClient = new SMTP.SmtpClient({ From e69eed772389e5cc427412d3b32657b153c62dd8 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:27:50 +0200 Subject: [PATCH 05/14] refactor(smtp): reuse SmtpCommand for auth reads and writes --- src/smtp/Auth.ts | 80 ++++++++---------------------------------------- 1 file changed, 13 insertions(+), 67 deletions(-) diff --git a/src/smtp/Auth.ts b/src/smtp/Auth.ts index 710f3cd..e983b3b 100644 --- a/src/smtp/Auth.ts +++ b/src/smtp/Auth.ts @@ -1,16 +1,22 @@ import type * as Types from '@app/Types.ts' +import { SmtpCommand } from '@smtp/Command.ts' /** * Authenticate SMTP session. * @description Supports AUTH LOGIN with AUTH PLAIN fallback. */ export class SmtpAuth { + /** SMTP command handler */ + private commands: SmtpCommand + /** * Create auth handler. * @description Stores shared connection state for auth flow. * @param state - Shared SMTP connection state */ - constructor(private state: Types.SmtpConnectionState) {} + constructor(private state: Types.SmtpConnectionState) { + this.commands = new SmtpCommand(state) + } /** * Perform SMTP authentication. @@ -25,81 +31,21 @@ export class SmtpAuth { const oauth2Payload = `user=${this.state.config.auth.user}\x01auth=Bearer ${this.state.config.auth.accessToken}\x01\x01` const encodedOAuth2 = btoa(oauth2Payload) - await this.sendCommand(`AUTH XOAUTH2 ${encodedOAuth2}`) + await this.commands.sendCommand(`AUTH XOAUTH2 ${encodedOAuth2}`) return } try { - await this.sendCommand('AUTH LOGIN') + await this.commands.sendCommand('AUTH LOGIN') const username = btoa(this.state.config.auth.user) - await this.sendCommand(username) + await this.commands.sendCommand(username) const password = btoa(this.state.config.auth.pass) - await this.sendCommand(password) + await this.commands.sendCommand(password) } catch { const credentials = `${this.state.config.auth.user}\0${this.state.config.auth.user}\0${this.state.config.auth.pass}` const encoded = btoa(credentials) - await this.sendCommand('AUTH PLAIN') - await this.sendCommand(encoded) - } - } - - /** - * 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.commands.sendCommand('AUTH PLAIN') + await this.commands.sendCommand(encoded) } - await this.readResponse() } } From e8e7c68580c7daa0de28e00a7a1ef0a99f57f5e3 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:06 +0200 Subject: [PATCH 06/14] refactor(smtp): keep client socket state in one place --- src/smtp/Client.ts | 50 +++++++++++++++------------------------------ src/smtp/Command.ts | 15 ++++++++++++++ 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/src/smtp/Client.ts b/src/smtp/Client.ts index 77a9ca7..c8eb8a2 100644 --- a/src/smtp/Client.ts +++ b/src/smtp/Client.ts @@ -13,14 +13,10 @@ export class SmtpClient { private commands: SMTP.SmtpCommand /** SMTP server configuration */ private config: Types.SmtpConnectionConfig - /** Raw TCP connection */ - private conn: Deno.Conn | null = null /** Internal connection state tracking */ private connectionState: Types.SmtpConnectionState /** Email message formatter */ private messageFormatter: SMTP.SmtpMessage - /** TLS encrypted connection */ - private tlsConn: Deno.TlsConn | null = null /** * Create SMTP client. @@ -30,8 +26,8 @@ export class SmtpClient { constructor(config: Types.SmtpConnectionConfig) { this.config = config this.connectionState = { - conn: this.conn, - tlsConn: this.tlsConn, + conn: null, + tlsConn: null, config: this.config } this.commands = new SMTP.SmtpCommand(this.connectionState) @@ -47,19 +43,17 @@ export class SmtpClient { async connect(): Promise { try { if (this.config.secure) { - this.tlsConn = await Deno.connectTls({ + this.connectionState.tlsConn = await Deno.connectTls({ hostname: this.config.host, port: this.config.port }) - this.connectionState.tlsConn = this.tlsConn await this.commands.readResponse() await this.commands.sendCommand(`EHLO ${this.config.host}`) } else { - this.conn = await Deno.connect({ + this.connectionState.conn = await Deno.connect({ hostname: this.config.host, port: this.config.port }) - this.connectionState.conn = this.conn await this.commands.readResponse() const ehloResponse = await this.commands.sendCommand(`EHLO ${this.config.host}`) const hasStartTlsSupport = /\bSTARTTLS\b/i.test(ehloResponse) @@ -87,25 +81,15 @@ export class SmtpClient { * @description Sends QUIT and closes active transport. */ async disconnect(): Promise { - if (this.tlsConn) { - try { - await this.commands.sendCommand('QUIT') - } catch { - // Ignore errors - } - this.tlsConn.close() - this.tlsConn = null - this.connectionState.tlsConn = null - } else if (this.conn) { - try { - await this.commands.sendCommand('QUIT') - } catch { - // Ignore errors - } - this.conn.close() - this.conn = null - this.connectionState.conn = null + if (!this.isConnected) { + return + } + try { + await this.commands.sendCommand('QUIT') + } catch { + // Ignore errors } + this.commands.close() } /** @@ -114,7 +98,7 @@ export class SmtpClient { * @returns True when connection is active */ get isConnected(): boolean { - return Boolean(this.conn || this.tlsConn) + return Boolean(this.connectionState.conn || this.connectionState.tlsConn) } /** @@ -125,7 +109,7 @@ export class SmtpClient { * @throws {Error} When message validation fails or transmission is unsuccessful */ async sendMessage(message: Types.EmailMessage): Promise { - if (!this.conn && !this.tlsConn) { + if (!this.isConnected) { throw new Error('Not connected to SMTP server') } if (message.attachments && message.attachments.length > 0) { @@ -282,14 +266,12 @@ export class SmtpClient { * @description Starts TLS over existing plain connection. */ private async upgradeToTLS(): Promise { - if (!this.conn) { + if (!this.connectionState.conn) { throw new Error('No connection to upgrade') } - this.tlsConn = await Deno.startTls(this.conn as Deno.TcpConn, { + this.connectionState.tlsConn = await Deno.startTls(this.connectionState.conn as Deno.TcpConn, { hostname: this.config.host }) - this.conn = null this.connectionState.conn = null - this.connectionState.tlsConn = this.tlsConn } } diff --git a/src/smtp/Command.ts b/src/smtp/Command.ts index a93f78f..72007a0 100644 --- a/src/smtp/Command.ts +++ b/src/smtp/Command.ts @@ -12,6 +12,21 @@ export class SmtpCommand { */ constructor(private state: Types.SmtpConnectionState) {} + /** + * Close active transport. + * @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 + try { + transport?.close() + } catch { + // Already closed + } + } + /** * Read server response. * @description Reads server reply until final status line. From b7b2c660a1a8a44980631e47d403e78d0cc39b38 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:17 +0200 Subject: [PATCH 07/14] feat(smtp): time out stalled server replies and socket writes --- src/smtp/Command.ts | 29 ++++++++++++++++++++++------- tests/smtp/Client.test.ts | 13 +++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/smtp/Command.ts b/src/smtp/Command.ts index 72007a0..594fbc6 100644 --- a/src/smtp/Command.ts +++ b/src/smtp/Command.ts @@ -1,4 +1,5 @@ import type * as Types from '@app/Types.ts' +import { defaultSocketTimeoutMs, withTimeout } from '@smtp/Timeout.ts' /** * Execute SMTP wire commands. @@ -32,6 +33,7 @@ export class SmtpCommand { * @description Reads server reply until final status line. * @returns Server response string * @throws {Error} When connection is closed or server returns error code + * @throws {SmtpTimeoutError} When server sends nothing within socket timeout */ async readResponse(): Promise { if (!this.state.conn && !this.state.tlsConn) { @@ -39,14 +41,18 @@ export class SmtpCommand { } const decoder = new TextDecoder() const buffer = new Uint8Array(1024) + const socketTimeoutMs = this.state.config.socketTimeoutMs ?? defaultSocketTimeoutMs 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 { + const transport = this.state.tlsConn ?? this.state.conn + if (!transport) { throw new Error('Connection closed') } + return await withTimeout( + transport.read(buffer), + socketTimeoutMs, + `SMTP server response timed out after ${socketTimeoutMs} ms`, + () => this.close() + ) } let response = '' while (true) { @@ -79,6 +85,7 @@ export class SmtpCommand { * @param command - SMTP command to send * @returns Server response string * @throws {Error} When not connected or server returns error + * @throws {SmtpTimeoutError} When write or response exceeds socket timeout */ async sendCommand(command: string): Promise { await this.writeAll(new TextEncoder().encode(`${command}\r\n`)) @@ -90,6 +97,7 @@ export class SmtpCommand { * @description Writes payload bytes without reading response. * @param data - Raw data to send * @throws {Error} When not connected to server + * @throws {SmtpTimeoutError} When a write exceeds socket timeout */ async sendData(data: string): Promise { await this.writeAll(new TextEncoder().encode(data)) @@ -97,18 +105,25 @@ export class SmtpCommand { /** * Write full payload to transport. - * @description Repeats partial socket writes until every byte is written. + * @description Repeats partial socket writes; each write is bounded by socket timeout. * @param payload - Bytes to write * @throws {Error} When not connected to server + * @throws {SmtpTimeoutError} When a write exceeds socket timeout */ private async writeAll(payload: Uint8Array): Promise { + const socketTimeoutMs = this.state.config.socketTimeoutMs ?? defaultSocketTimeoutMs let bytesWritten = 0 while (bytesWritten < payload.length) { const transport = this.state.tlsConn ?? this.state.conn if (!transport) { throw new Error('Not connected') } - bytesWritten += await transport.write(payload.subarray(bytesWritten)) + bytesWritten += await withTimeout( + transport.write(payload.subarray(bytesWritten)), + socketTimeoutMs, + `SMTP socket write timed out after ${socketTimeoutMs} ms`, + () => this.close() + ) } } } diff --git a/tests/smtp/Client.test.ts b/tests/smtp/Client.test.ts index 1caf940..64acf56 100644 --- a/tests/smtp/Client.test.ts +++ b/tests/smtp/Client.test.ts @@ -9,6 +9,19 @@ const testMessage = { text: 'Hello' } +Deno.test('SmtpClient connect closes socket when greeting exceeds socketTimeoutMs', async () => { + const server = startSmtpServer(['greeting']) + const smtpClient = new SMTP.SmtpClient({ + host: '127.0.0.1', + port: server.port, + socketTimeoutMs: 200 + }) + await assertRejects(() => smtpClient.connect(), Error, 'timed out') + assertEquals(smtpClient.isConnected, false) + const [outcome] = await server.outcomes + assertEquals(outcome?.clientClosed, true) +}) + Deno.test('SmtpClient sendMessage delivers DATA larger than one socket write', async () => { const server = startSmtpServer(['none']) const smtpClient = new SMTP.SmtpClient({ From 4ff3ae480b1a0dcfde9e4290fc9d386b6f946692 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:28 +0200 Subject: [PATCH 08/14] fix(smtp): don't count RCPT timeouts as rejected recipients --- src/smtp/Client.ts | 5 ++++- tests/smtp/Client.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/smtp/Client.ts b/src/smtp/Client.ts index c8eb8a2..bde0475 100644 --- a/src/smtp/Client.ts +++ b/src/smtp/Client.ts @@ -146,7 +146,10 @@ export class SmtpClient { try { await this.commands.sendCommand(`RCPT TO:<${recipient.email}>`) acceptedRecipients.push(recipient.email) - } catch { + } catch (error) { + if (error instanceof SMTP.SmtpTimeoutError) { + throw error + } rejectedRecipients.push(recipient.email) } } diff --git a/tests/smtp/Client.test.ts b/tests/smtp/Client.test.ts index 64acf56..9d2e364 100644 --- a/tests/smtp/Client.test.ts +++ b/tests/smtp/Client.test.ts @@ -56,3 +56,17 @@ Deno.test('SmtpClient sendMessage rejects when not connected to server', async ( 'Not connected to SMTP server' ) }) + +Deno.test('SmtpClient sendMessage reports RCPT timeout instead of rejected recipients', async () => { + const server = startSmtpServer(['rcpt']) + const smtpClient = new SMTP.SmtpClient({ + host: '127.0.0.1', + port: server.port, + socketTimeoutMs: 200 + }) + await smtpClient.connect() + await assertRejects(() => smtpClient.sendMessage(testMessage), Error, 'timed out') + assertEquals(smtpClient.isConnected, false) + const [outcome] = await server.outcomes + assertEquals(outcome?.clientClosed, true) +}) From 9e49d67780cc5f867f53890535f5a475679c3678 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:28 +0200 Subject: [PATCH 09/14] fix(smtp): skip AUTH PLAIN fallback after a timeout --- src/smtp/Auth.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/smtp/Auth.ts b/src/smtp/Auth.ts index e983b3b..9bbb22b 100644 --- a/src/smtp/Auth.ts +++ b/src/smtp/Auth.ts @@ -1,5 +1,6 @@ import type * as Types from '@app/Types.ts' import { SmtpCommand } from '@smtp/Command.ts' +import { SmtpTimeoutError } from '@smtp/Timeout.ts' /** * Authenticate SMTP session. @@ -40,7 +41,10 @@ export class SmtpAuth { await this.commands.sendCommand(username) const password = btoa(this.state.config.auth.pass) await this.commands.sendCommand(password) - } catch { + } catch (error) { + if (error instanceof SmtpTimeoutError) { + throw error + } const credentials = `${this.state.config.auth.user}\0${this.state.config.auth.user}\0${this.state.config.auth.pass}` const encoded = btoa(credentials) From f9223080ba497e3f3c7dc3ba3b652eb4c6a20764 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:34 +0200 Subject: [PATCH 10/14] feat(smtp): bound TCP connect and TLS handshake time --- src/smtp/Client.ts | 58 +++++++++++++++++++++++++++++---------- tests/smtp/Client.test.ts | 15 ++++++++++ 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/smtp/Client.ts b/src/smtp/Client.ts index bde0475..e51b0df 100644 --- a/src/smtp/Client.ts +++ b/src/smtp/Client.ts @@ -42,20 +42,13 @@ export class SmtpClient { */ async connect(): Promise { try { + await this.openTcpConnection() if (this.config.secure) { - this.connectionState.tlsConn = await Deno.connectTls({ - hostname: this.config.host, - port: this.config.port - }) - await this.commands.readResponse() - await this.commands.sendCommand(`EHLO ${this.config.host}`) - } else { - this.connectionState.conn = await Deno.connect({ - hostname: this.config.host, - port: this.config.port - }) - await this.commands.readResponse() - const ehloResponse = await this.commands.sendCommand(`EHLO ${this.config.host}`) + await this.upgradeToTLS() + } + await this.commands.readResponse() + const ehloResponse = await this.commands.sendCommand(`EHLO ${this.config.host}`) + if (!this.config.secure) { const hasStartTlsSupport = /\bSTARTTLS\b/i.test(ehloResponse) if (this.config.port === 587 && !hasStartTlsSupport) { throw new Error('STARTTLS is required on port 587 but server does not advertise support') @@ -264,17 +257,52 @@ export class SmtpClient { return `${dkimHeader}\r\n${rawHeaderSection}\r\n\r\n${rawBodySection}` } + /** + * Open TCP connection. + * @description Connects to configured host within connection timeout. + * @throws {SmtpTimeoutError} When TCP connect exceeds connection timeout + */ + private async openTcpConnection(): Promise { + const connectionTimeoutMs = this.config.connectionTimeoutMs ?? SMTP.defaultConnectionTimeoutMs + const connectAbort = new AbortController() + const pendingConn = Deno.connect({ + hostname: this.config.host, + port: this.config.port, + signal: connectAbort.signal + }) + this.connectionState.conn = await SMTP.withTimeout( + pendingConn, + 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(), () => {}) + } + ) + } + /** * Upgrade transport to TLS. - * @description Starts TLS over existing plain connection. + * @description Starts TLS over plain connection and completes handshake within connection timeout. + * @throws {Error} When no plain connection exists or handshake fails + * @throws {SmtpTimeoutError} When TLS handshake exceeds connection timeout */ private async upgradeToTLS(): Promise { if (!this.connectionState.conn) { throw new Error('No connection to upgrade') } - this.connectionState.tlsConn = await Deno.startTls(this.connectionState.conn as Deno.TcpConn, { + 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(), + connectionTimeoutMs, + `TLS handshake with ${this.config.host}:${this.config.port} timed out after ${connectionTimeoutMs} ms`, + () => this.commands.close() + ) } } diff --git a/tests/smtp/Client.test.ts b/tests/smtp/Client.test.ts index 9d2e364..13fd285 100644 --- a/tests/smtp/Client.test.ts +++ b/tests/smtp/Client.test.ts @@ -9,6 +9,21 @@ const testMessage = { text: 'Hello' } +Deno.test('SmtpClient connect rejects when TLS handshake exceeds connectionTimeoutMs', async () => { + // Deno keeps the TCP socket under an unfinished TLS handshake open until the peer answers or + // hangs up, so this server hangs up itself shortly after the client gives up. + const server = startSmtpServer(['greeting'], 500) + const smtpClient = new SMTP.SmtpClient({ + host: '127.0.0.1', + port: server.port, + secure: true, + connectionTimeoutMs: 200 + }) + await assertRejects(() => smtpClient.connect(), Error, 'TLS handshake') + assertEquals(smtpClient.isConnected, false) + await server.outcomes +}) + Deno.test('SmtpClient connect closes socket when greeting exceeds socketTimeoutMs', async () => { const server = startSmtpServer(['greeting']) const smtpClient = new SMTP.SmtpClient({ From 220a99736c191f00d1f463cffd1161dbb494e5c1 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:34 +0200 Subject: [PATCH 11/14] test(mailer): cover pool reconnect after a timed-out send --- tests/index.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index 92f0687..4ce9973 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,27 @@ -import { assertEquals, assertThrows } from '@std/assert' +import { assertEquals, assertRejects, assertThrows } from '@std/assert' import * as App from '@app/index.ts' +import { startSmtpServer } from '@tests/helpers/SmtpServer.ts' + +Deno.test('mailer pooled transporter reconnects after a timed-out send', async () => { + const server = startSmtpServer(['greeting', 'none']) + const sender = App.mailer.transporter({ + host: '127.0.0.1', + port: server.port, + socketTimeoutMs: 200, + pool: { idleTimeoutMs: 0 } + }) + const message = { + from: 'sender@example.com', + to: 'receiver@example.com', + subject: 'Test', + text: 'Hello' + } + await assertRejects(() => sender.send(message), Error, 'timed out') + const sendResult = await sender.send(message) + assertEquals(sendResult.response, '250 OK queued\r\n') + const outcomes = await server.outcomes + assertEquals(outcomes.map((outcome) => outcome.clientClosed), [true, true]) +}) Deno.test('mailer transporter rejects invalid config', () => { assertThrows( From c4472ebdbc50d2610d3e7919fad2151807438a39 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:34 +0200 Subject: [PATCH 12/14] docs(usage): document connect and socket timeouts --- USAGE.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/USAGE.md b/USAGE.md index 829293b..c2c839a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -11,6 +11,7 @@ This document explains the full **Deno Mailer** usage flow, from SMTP configurat - [DKIM Signing](#dkim-signing) - [Pooling and Reuse](#pooling-and-reuse) - [TLS and SSL Options](#tls-and-ssl-options) + - [Timeouts](#timeouts) - [Basic Usage](#basic-usage) - [Simple Text Email](#simple-text-email) - [HTML Email](#html-email) @@ -179,6 +180,31 @@ With `secure: true`, the connection uses TLS from the first byte (typical for po } ``` +### Timeouts + +Every network wait is bounded, so an unresponsive server cannot hold `send()` or its socket open indefinitely. When a limit is hit, the client closes its socket and `send()` rejects with a message containing `timed out`. A pooled client that timed out reconnects on its next use. + +| Option | Default | Limits | +| --------------------- | ------- | -------------------------------------------------------------------------- | +| `connectionTimeoutMs` | `30000` | TCP connect, and each TLS handshake (`secure: true` or after `STARTTLS`) | +| `socketTimeoutMs` | `60000` | Each wait for a server reply and each socket write (inactivity, not total) | + +```ts +// Fail fast against unresponsive servers. +const transporter = mailer.transporter({ + host: 'smtp.example.com', + port: 587, + secure: false, + connectionTimeoutMs: 10000, + socketTimeoutMs: 15000 +}) +``` + +`socketTimeoutMs` measures inactivity: a large message keeps uploading as long as each write makes progress, so one `send()` can take longer than either value. Wrap `send()` in your own deadline if you need a cap on total time. + +> [!NOTE] +> If a server stops responding in the middle of a TLS handshake, `send()` still rejects on time, but the Deno runtime keeps that TCP socket open until the server answers or disconnects. Replies and writes after the handshake are not affected. + ## Basic Usage ### Simple Text Email @@ -499,6 +525,8 @@ Rules: | `host` | string | yes | SMTP server hostname | `'smtp.gmail.com'` | | `port` | number | yes | SMTP server port | `587`, `465`, `25` | | `secure` | boolean | no | Implicit TLS from connect | `true` (465), `false` or omit (587) | +| `connectionTimeoutMs` | number | no | TCP connect and TLS handshake | `30000` | +| `socketTimeoutMs` | number | no | Server reply and write idle | `60000` | | `auth` | object | no | Omit for servers without AUTH | See [Authentication](#authentication) | | `auth.type` | string | when `auth` set | Password or OAuth2 | `'password'`, `'oauth2'` | | `auth.user` | string | when `auth` set | SMTP username | `'user@example.com'` | @@ -631,6 +659,7 @@ const transporter = mailer.transporter({ - Verify SMTP host and port values - Check firewall or network restrictions - Try alternative SMTP ports (`587`, `465`, `25`) +- Errors containing `timed out` come from `connectionTimeoutMs` (connect or TLS handshake) or `socketTimeoutMs` (server reply or write); see [Timeouts](#timeouts) ### TLS Errors From 576de5c24a2c520e1230275a8072115d45e6324d Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:34 +0200 Subject: [PATCH 13/14] docs(readme): mention bounded network timeouts --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cdc1873..9975963 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Lightweight Deno SMTP mailer with flexible configuration and formatting. - **Simple SMTP transport**: create transporter and send emails with minimal setup - **Connection pooling support**: reuse SMTP clients with configurable pool limits +- **Bounded network timeouts**: connect, TLS handshake, and socket inactivity limits close stalled connections - **DKIM signing support**: sign outgoing messages with RSA private key - **Flexible recipients**: supports string, object, and mixed recipient formats - **Rich message content**: plain text, HTML, mixed body, and custom headers From 1af835d47392392ea36036fb8627b4dc0b9b0a28 Mon Sep 17 00:00:00 2001 From: neonbyte1 <186292855+neonbyte1@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:28:34 +0200 Subject: [PATCH 14/14] docs(changelog): add unreleased timeout and write fixes --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7616934..fbe8086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- 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`) +- Added loopback SMTP server test helper and transport timeout regression tests + +### Changed + +- 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 + +### Fixed + +- Fixed SMTP writes ignoring partial socket writes, which truncated messages larger than one write (64 KiB over TLS) and left `send()` waiting indefinitely + ## [0.3.0] - 2026-03-25 ### Added