Repository navigation
Conversation
setTimeout returns an object from Deno 2.6 onwards, so the pooled idle timer map no longer accepts the handle and `deno check` fails. The CI pin at v2.5.4 hid this: the check only passes on that exact release. Name the handle as SMTP.TimerHandle where the timer helpers live and use it for the map, which is correct on every runtime. Also stop `deno task check` from running `deno fmt` in write mode. Formatting drift could never fail CI because the task rewrote the files it was supposed to be checking. Use `deno task fmt` to format.
Nothing reaches it. SmtpClient builds SmtpCommand, SmtpAuth and SmtpMessage, never SmtpConnection, so the class has been dead since the client was reorganised. It is also a worse copy of the code that does run. No read, write or connect timeouts, a single write() call that silently truncates payloads, a reply reader that stops at the first CRLF and so mangles multi-line 250- responses, and a handshake that only does HELO and only tries STARTTLS on port 587. Keeping it means porting all of that to the new transport, or shipping it broken on Node and Bun too. It is exported, so this is a breaking removal.
The SMTP layer talked to Deno.connect and Deno.startTls directly, and SmtpConnectionState exposed Deno.Conn and Deno.TlsConn through the published types, so neither the code nor its type surface could leave Deno. Put an SmtpTransport interface in front of the socket. Deno keeps its native path, so nothing changes for existing JSR consumers. Node and Bun get a node:net and node:tls implementation that buffers incoming chunks and parks one reader, which keeps SmtpCommand's pull-based read(target) working against push-based streams. The implementation is picked on first connect and loaded with a dynamic import, so neither runtime ever loads the other's module. Two behaviour notes: startTls now resolves only once the handshake has completed, so a single withTimeout covers the whole upgrade instead of the old split between Deno.startTls and handshake(). Because the secured socket only exists inside that pending call, both connect and startTls take an AbortSignal; without it a timed-out handshake orphaned the socket. Deno 2.5.4's leak detector caught exactly that. The Node upgrade refuses to start when bytes are already buffered. Plaintext that arrives before the handshake was never asked for, and folding it into the secured session is the classic STARTTLS command injection. SmtpConnectionState now carries one `transport` field instead of `conn` and `tlsConn`. Breaking, hence 0.4.0.
Deno runs the Deno transport, so the Node one would otherwise ship untested. Deno's node:net support lets the suite drive it directly: a full scripted SMTP session with a 56 KiB DATA payload, reads split across a 4-byte buffer with a null at end of stream, an aborted connect, the refusal to upgrade with buffered plaintext, and an abandoned handshake. The handshake test targets 127.0.0.1 on purpose. node:tls rejects an IP offered as an SNI name, so that case has to stay covered.
Node cannot run src/ directly because of the @app/ style import map aliases and the .ts specifiers, so there was no way to exercise the Node path end to end before publishing. `deno task bundle` emits a Node-runnable ESM file with node: builtins left external, and scripts/smoke.mjs sends a real message through it to a loopback SMTP server: once in plaintext and once over a STARTTLS upgrade. It checks both sides, the returned result and the bytes the server actually received, so a transport that truncates writes or skips the upgrade fails here. The STARTTLS leg needs the throwaway certificate in the trust store and NODE_EXTRA_CA_CERTS is only read at startup, so the script generates the certificate and re-runs itself once.
The single pinned Deno version is what let the timer handle break reach main unnoticed, so the check and test job now runs the pin and the latest v2.x side by side. Adds smoke jobs for Node 24 (current LTS) and Node 22 (the floor the package claims) plus Bun, and refreshes the action pins: checkout v4 to v7 and setup-node v4 to v7.
Install instructions for all three runtimes. Node and Bun install from JSR with `jsr add`, which registers the @jsr registry and serves the same source as ESM with type declarations, so the import specifier does not change. Also drops the Deno.env call from the DKIM example, since it is the one snippet that would not run as written on Node or Bun.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two things here. They're related, because the first one is what stopped me from trusting anything else.
The pipeline
The red runs on
mainaren't a code problem. The annotation on the failed job says it straight out:Runs 14 and 15 finished in three seconds with no steps executed, and run 13 never created a job at all. Run 12 was just waiting on fork-PR approval. None of that is fixable from this repo.
But while checking that, I found a real failure the pin was hiding:
setTimeoutreturns an object from Deno 2.6 onwards. CI pinnedv2.5.4, which is the last release where this still type-checks, sodeno task checkpasses on exactly one version of Deno and fails on everything newer. The moment anyone bumps the pin, main goes red.Fixed by naming the handle type where the timer helpers live and using it for the pool map. CI now runs the pin and the latest
v2.xtogether so this can't drift silently again.One more thing in that area:
deno task checkwas runningdeno fmtin write mode. It rewrote the files it was supposed to be checking, so formatting drift could never fail CI. It's--checknow, withdeno task fmtfor formatting.Node and Bun
The coupling turned out to be narrow —
Deno.connect,Deno.startTls, andDeno.Conn/Deno.TlsConnleaking into the exportedSmtpConnectionState. Everything else (crypto.subtle,btoa,Promise.withResolvers) was already portable.So: an
SmtpTransportinterface in front of the socket. Deno keeps its native path, so nothing changes for anyone on JSR today. Node and Bun get anode:net/node:tlsimplementation that buffers incoming chunks and parks one reader, which is what letsSmtpCommandkeep its pull-basedread(target)against push-based streams. The right one is picked on first connect via dynamic import, so neither runtime loads the other's module.No npm build. I wrote one with dnt first, then deleted it — JSR already publishes an npm-consumable package, and I confirmed it by installing the live
0.3.0throughnpx jsr addand importing it on Node 26 and Bun 1.4. A second artifact on npmjs.com would only have boughtnpm installergonomics in exchange for a build script and a version that can drift.deno task bundlecovers the one thing still genuinely needed: something Node can actually run for the smoke tests.Three bugs the port shook out
startTls+handshake()into one call left the new socket unreachable, so a timeout orphaned it. Deno 2.5.4's leak detector caught this. BothconnectandstartTlstake anAbortSignalnow. This one affected the Deno path too.servernameset to an IP.node:tlsrejects it outright;Deno.startTlsaccepts it. Connecting to an SMTP host by address would have failed on Node and Bun only. SNI is omitted for IP literals now, withhostcarrying the identity check.Breaking
Version goes to 0.4.0:
SmtpConnectionStatecarries onetransportfield instead ofconn+tlsConn.SmtpConnectionis gone. Nothing reached it —SmtpClientbuildsSmtpCommand/SmtpAuth/SmtpMessageand never touched it. It was also a worse copy of the live code: no timeouts, a singlewrite()that silently truncates, a reply reader that stops at the first CRLF and mangles multi-line250-responses, HELO-only handshake. Porting it meant carrying all of that onto the new transport for zero callers.Verification
deno task check/teston 2.5.4deno task check/teston 2.9.7plaintext and starttlsplaintext and starttlsdeno publish --dry-runThe smoke test sends a real message through the bundled library to a loopback SMTP server, once plaintext and once over a STARTTLS upgrade with a throwaway cert, and checks both the returned result and the bytes the server received — so a transport that truncates writes or quietly skips the upgrade fails it.
One question before you merge
Are you planning to sort out the billing lock on the account?
I'm asking because none of the above can actually be verified by you until you do. Every job in here will be skipped the same way runs 13 to 15 were, the badge in the README stays red, and
deno publishwon't run either, so 0.4.0 can't reach JSR. I ran the full matrix locally and the results are in the table above, but you'd be merging on my word rather than on a green check.If it's getting fixed soon, I'm happy to wait and rerun everything against real CI. If it isn't, I'd rather publish my fork under its own scope than leave Node and Bun support sitting in a branch — no hard feelings either way, I'd just want to tell people where to get it rather than have two unrelated packages drift apart quietly. Let me know which it is.
Notes
>=24, that's a one-line change plus the docs.checkoutwent v4 to v7 andsetup-nodev4 to v7 while I was in there.