Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'` |
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 */
Expand Down
86 changes: 18 additions & 68 deletions src/smtp/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type * as Types from '@app/Types.ts'
import { SmtpCommand } from '@smtp/Command.ts'
import { SmtpTimeoutError } from '@smtp/Timeout.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.
Expand All @@ -25,81 +32,24 @@ 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)
} catch {
await this.commands.sendCommand(password)
} 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)
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<string> {
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<number | null> => {
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<string> => {
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<void> {
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()
}
}
Loading