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 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() } }) ``` diff --git a/deno.json b/deno.json index 52c59c4..c8490bd 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", @@ -61,13 +61,18 @@ "exclude": ["tests/**/*.d.ts"] }, "tasks": { - "check": "deno fmt src/ && deno lint src/ && deno check src/", - "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", "@app/": "./src/", "@smtp/": "./src/smtp/", + "@runtime/": "./src/runtime/", "@utils/": "./src/utils/", "@tests/": "./tests/" }, 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() 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 a407f50..232117e 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 */ @@ -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') } 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/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 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/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}`) +}) 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') })