Skip to content

fix(smtp): add connect and socket timeouts and write full payloads - #2

Merged
NeaByteLab merged 14 commits into
NeaByteLab:mainfrom
neonbyte1:fix/smtp-timeouts
Oct 8, 2026
Merged

NeaByteLab merged 14 commits into
NeaByteLab:mainfrom
neonbyte1:fix/smtp-timeouts

Conversation

@neonbyte1

Copy link
Copy Markdown
Contributor

Fixes #1.

Changes

  • New config options
    • connectionTimeoutMs (default 30000): TCP connect and each TLS handshake (secure: true or after STARTTLS).
    • socketTimeoutMs (default 60000): each wait for a server reply and each socket write. It is an inactivity limit, so a large upload that keeps making progress is not cut off.
    • Both validated as integers in 1..2147483647 (the setTimeout range).
  • On timeout the client closes its own socket and clears its state; send() rejects with ... timed out after N ms. A pooled client that timed out reconnects on next use instead of reusing a dead socket.
  • Full writes: SmtpCommand loops over partial write() results, each write bounded by socketTimeoutMs. This fixes DATA truncation above one write (64 KiB over TLS).
  • RCPT TO and the AUTH LOGIN → PLAIN fallback rethrow SmtpTimeoutError instead of treating it as a rejection.
  • Implicit TLS now does Deno.connect (abortable via signal) followed by Deno.startTls + handshake(), so connect and handshake are bounded separately.
  • SmtpAuth reuses SmtpCommand wire I/O (removes its duplicate reader/writer); SmtpClient keeps transport state only in connectionState.
  • New src/smtp/Timeout.ts (withTimeout, SmtpTimeoutError, defaults), exported through the @smtp barrel.
  • Docs: USAGE "Timeouts" section, options table, troubleshooting entry; README feature line; CHANGELOG [Unreleased].

Tests

tests/helpers/SmtpServer.ts is a scripted loopback SMTP server that records whether the client hung up. New tests, all of which fail or hang on 0.3.0:

Test 0.3.0
greeting stall closes socket within socketTimeoutMs Connection closed after the server gave up
TLS handshake stall rejects within connectionTimeoutMs unexpected end of file after the server gave up
RCPT stall reports a timeout All recipients were rejected by SMTP server
10 MiB DATA is delivered intact hangs
pooled transporter reconnects after a timed-out send reuses the dead socket
timeout validation boundaries (0, 1, 2147483647, 2147483648) accepted silently

Deno's default op/resource sanitizers stay on, so the tests also catch leaked sockets and timers.

Verification

  • deno task check (fmt, lint, check) and the full suite: 112/112 on Deno 2.5.4 (the CI version, type-checked) and 2.9.7; new transport tests stable over repeated runs.
  • Live smtp.ethereal.email:587 (STARTTLS + AUTH): integration suite passes; a 1.9 MiB message sent twice over one pooled connection.
  • Implicit TLS against a local TLS SMTP server: a 1 MiB message hangs on 0.3.0 and is delivered in ~120 ms with this change.
  • Blackholed host with connectionTimeoutMs: 1500: rejected after 1507 ms.

Known limitation

When a server stalls during the TLS handshake, send() rejects on time, but Deno keeps that TCP socket open until the peer answers or disconnects (details in #1). It is documented in USAGE. Timeouts after the handshake close the socket immediately.

Not included

src/index.ts:157 still fails deno check on Deno 2.9+ (setTimeout typing). That predates this PR and is left out to keep it focused.

@NeaByteLab

Copy link
Copy Markdown
Owner

Thanks for the thorough investigation in #1 and the clean fix here.

The changes look solid:

  • Resolves the 64 KiB TLS write truncation properly via writeAll
  • Clean timeout separation between connectionTimeoutMs and socket inactivity socketTimeoutMs
  • Proper cleanup on timeout so pooled clients don't reuse dead sockets
  • Solid test coverage with the loopback SMTP server helper

LGTM. Merging this now.

@NeaByteLab
NeaByteLab merged commit 65d35df into NeaByteLab:main Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No connect/read/write timeouts: stalled servers keep send() and its socket open; messages over 64 KiB truncated on TLS

2 participants