Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
55792c6e66 fix(http): prevent out-of-bounds write in buildRequest with excessive headers
The buildRequest function copied user-provided headers into a fixed-size
global array of 256 picohttp.Header entries without bounds checking. When
more than ~250 headers were provided via fetch(), the function would write
past the end of shared_request_headers_buf, corrupting adjacent memory
(shared_response_headers_buf) in release builds or panicking in debug builds.

Fix by dynamically allocating a larger buffer when the header count exceeds
the static buffer capacity, with the overflow buffer cached for reuse. Also
add bounds checks as a safety net for the allocation failure fallback path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 04:50:09 +00:00
46 changed files with 188 additions and 12498 deletions

View File

@@ -1,33 +1,3 @@
# Task: SMTP client
Implement a comprehensive SMTP client API for Bun in Zig using Bun's event loop, usockets, string & URL parsing utilities, and BoringSSL.
Features:
- Security focused - designed to avoid remote code execution vulnerabilities that have affected other Node.js email libraries.
- Full Unicode support - send messages with any characters, including emoji 💪.
- HTML and plain-text emails - send rich HTML emails with automatic plain-text fallbacks.
- Attachments and embedded images - easily include files and inline images in your messages.
- Built-in TLS/STARTTLS encryption - secure connections are handled automatically.
- Multiple transports - send via SMTP, Sendmail, Amazon SES, streams, and more.
- DKIM signing and OAuth2 authentication - enterprise-ready email authentication.
- Proxy support - route email through proxies for restricted network environments.
- Ethereal.email integration - generate test accounts instantly for local development and testing.
Location: Put it in src/smtp/**.
Tests: Put it in test/bun/api/smtp. Port tests from vendor/nodemailer.
References:
- vendor/nodemailer: Look at nodemailer's source code for inspiration of how various protocols and handshaking and TLS and implementation details of a production-ready SMTP client.
- src/sql/mysql/js/JSMySQLConnection.zig: MySQL client that uses TCP & TLS with non-blocking sockets
- src/sql/postgres/PostgresSQLConnection.zig: PostgreSQL client
- src/valkey/js_valkey.zig: Valkey (Redis) client
To expose the API to JavaScript, use the implementing-jsc-classes-zig skill.
---
This is the Bun repository - an all-in-one JavaScript runtime & toolkit designed for speed, with a bundler, test runner, and Node.js-compatible package manager. It's written primarily in Zig with C++ for JavaScriptCore integration, powered by WebKit's JavaScriptCore engine.
## Building and Running Bun

View File

@@ -82,7 +82,6 @@ pub const Features = struct {
pub var postgres_connections: usize = 0;
pub var s3: usize = 0;
pub var valkey: usize = 0;
pub var smtp: usize = 0;
pub var csrf_verify: usize = 0;
pub var csrf_generate: usize = 0;
pub var unsupported_uv_function: usize = 0;

View File

@@ -54,7 +54,6 @@ pub const ResolveMessage = @import("./ResolveMessage.zig").ResolveMessage;
pub const Shell = @import("../shell/shell.zig");
pub const UDPSocket = @import("./api/bun/udp_socket.zig").UDPSocket;
pub const Valkey = @import("../valkey/js_valkey.zig").JSValkeyClient;
pub const SMTPClient = @import("../smtp/JSSMTPClient.zig");
pub const BlockList = @import("./node/net/BlockList.zig");
pub const NativeZstd = @import("./node/zlib/NativeZstd.zig");

View File

@@ -30,7 +30,6 @@ pub const BunObject = struct {
pub const registerMacro = toJSCallback(Bun.registerMacro);
pub const resolve = toJSCallback(Bun.resolve);
pub const resolveSync = toJSCallback(Bun.resolveSync);
pub const email = toJSCallback(jsc.API.SMTPClient.jsEmail);
pub const serve = toJSCallback(Bun.serve);
pub const sha = toJSCallback(host_fn.wrapStaticMethod(Crypto.SHA512_256, "hash_", true));
pub const shellEscape = toJSCallback(Bun.shellEscape);
@@ -82,7 +81,6 @@ pub const BunObject = struct {
pub const s3 = toJSLazyPropertyCallback(Bun.getS3DefaultClient);
pub const ValkeyClient = toJSLazyPropertyCallback(Bun.getValkeyClientConstructor);
pub const valkey = toJSLazyPropertyCallback(Bun.getValkeyDefaultClient);
pub const SMTPClient = toJSLazyPropertyCallback(Bun.getSMTPClientConstructor);
pub const Terminal = toJSLazyPropertyCallback(Bun.getTerminalConstructor);
// --- Lazy property callbacks ---
@@ -153,7 +151,6 @@ pub const BunObject = struct {
@export(&BunObject.s3, .{ .name = lazyPropertyCallbackName("s3") });
@export(&BunObject.ValkeyClient, .{ .name = lazyPropertyCallbackName("ValkeyClient") });
@export(&BunObject.valkey, .{ .name = lazyPropertyCallbackName("valkey") });
@export(&BunObject.SMTPClient, .{ .name = lazyPropertyCallbackName("SMTPClient") });
@export(&BunObject.Terminal, .{ .name = lazyPropertyCallbackName("Terminal") });
// --- Lazy property callbacks ---
@@ -165,7 +162,6 @@ pub const BunObject = struct {
@export(&BunObject.createParsedShellScript, .{ .name = callbackName("createParsedShellScript") });
@export(&BunObject.createShellInterpreter, .{ .name = callbackName("createShellInterpreter") });
@export(&BunObject.deflateSync, .{ .name = callbackName("deflateSync") });
@export(&BunObject.email, .{ .name = callbackName("email") });
@export(&BunObject.file, .{ .name = callbackName("file") });
@export(&BunObject.gunzipSync, .{ .name = callbackName("gunzipSync") });
@export(&BunObject.gzipSync, .{ .name = callbackName("gzipSync") });
@@ -1342,10 +1338,6 @@ pub fn getValkeyClientConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObj
return jsc.API.Valkey.js.getConstructor(globalThis);
}
pub fn getSMTPClientConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return jsc.API.SMTPClient.js.getConstructor(globalThis);
}
pub fn getTerminalConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.Terminal.js.getConstructor(globalThis);
}

View File

@@ -63,7 +63,6 @@ pub const Tag = enum {
MySQLConnectionMaxLifetime,
ValkeyConnectionTimeout,
ValkeyConnectionReconnect,
SMTPConnectionTimeout,
SubprocessTimeout,
DevServerSweepSourceMaps,
DevServerMemoryVisualizerTick,
@@ -89,7 +88,6 @@ pub const Tag = enum {
.SubprocessTimeout => jsc.Subprocess,
.ValkeyConnectionReconnect => jsc.API.Valkey,
.ValkeyConnectionTimeout => jsc.API.Valkey,
.SMTPConnectionTimeout => jsc.API.SMTPClient,
.DevServerSweepSourceMaps,
.DevServerMemoryVisualizerTick,
=> bun.bake.DevServer,
@@ -174,7 +172,6 @@ pub fn fire(self: *Self, now: *const timespec, vm: *VirtualMachine) void {
.MySQLConnectionMaxLifetime => @as(*api.MySQL.MySQLConnection, @alignCast(@fieldParentPtr("max_lifetime_timer", self))).onMaxLifetimeTimeout(),
.ValkeyConnectionTimeout => @as(*api.Valkey, @alignCast(@fieldParentPtr("timer", self))).onConnectionTimeout(),
.ValkeyConnectionReconnect => @as(*api.Valkey, @alignCast(@fieldParentPtr("reconnect_timer", self))).onReconnectTimer(),
.SMTPConnectionTimeout => @as(*api.SMTPClient, @alignCast(@fieldParentPtr("timer", self))).onConnectionTimeout(),
.DevServerMemoryVisualizerTick => bun.bake.DevServer.emitMemoryVisualizerMessageTimer(self, now),
.DevServerSweepSourceMaps => bun.bake.DevServer.SourceMapStore.sweepWeakRefs(self, now),
.AbortSignalTimeout => {

View File

@@ -1,44 +0,0 @@
import { define } from "../../codegen/class-definitions";
export default [
define({
name: "SMTPClient",
construct: true,
constructNeedsThis: true,
finalize: true,
configurable: false,
JSType: "0b11101110",
memoryCost: true,
klass: {
parseAddress: {
fn: "jsParseAddress",
length: 2,
},
createTestAccount: {
fn: "jsCreateTestAccount",
length: 0,
},
},
proto: {
send: {
fn: "send",
length: 1,
},
verify: {
fn: "verify",
length: 0,
},
close: {
fn: "close",
length: 0,
},
connected: {
getter: "getConnected",
},
secure: {
getter: "getSecure",
},
},
values: ["sendPromise"],
}),
];

View File

@@ -15,7 +15,6 @@
macro(markdown) \
macro(MD5) \
macro(S3Client) \
macro(SMTPClient) \
macro(SHA1) \
macro(SHA224) \
macro(SHA256) \
@@ -50,7 +49,6 @@
macro(createParsedShellScript) \
macro(createShellInterpreter) \
macro(deflateSync) \
macro(email) \
macro(file) \
macro(fs) \
macro(gc) \

View File

@@ -940,7 +940,6 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
deepMatch functionBunDeepMatch DontDelete|Function 2
deflateSync BunObject_callback_deflateSync DontDelete|Function 1
dns constructDNSObject ReadOnly|DontDelete|PropertyCallback
email BunObject_callback_email DontDelete|Function 1
enableANSIColors BunObject_lazyPropCb_wrap_enableANSIColors DontDelete|PropertyCallback
env constructEnvObject ReadOnly|DontDelete|PropertyCallback
escapeHTML functionBunEscapeHTML DontDelete|Function 2
@@ -1004,7 +1003,6 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
version constructBunVersion ReadOnly|DontDelete|PropertyCallback
which BunObject_callback_which DontDelete|Function 1
RedisClient BunObject_lazyPropCb_wrap_ValkeyClient DontDelete|PropertyCallback
SMTPClient BunObject_lazyPropCb_wrap_SMTPClient DontDelete|PropertyCallback
redis BunObject_lazyPropCb_wrap_valkey DontDelete|PropertyCallback
secrets constructSecretsObject DontDelete|PropertyCallback
write BunObject_callback_write DontDelete|Function 1

View File

@@ -954,7 +954,6 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionBunPluginClear, (JSC::JSGlobalObject * global
global->onResolvePlugins.namespaces.clear();
delete global->onLoadPlugins.virtualModules;
global->onLoadPlugins.virtualModules = nullptr;
return JSC::JSValue::encode(JSC::jsUndefined());
}

View File

@@ -91,7 +91,6 @@ pub const Classes = struct {
pub const BlockList = api.BlockList;
pub const NativeZstd = api.NativeZstd;
pub const SourceMap = bun.SourceMap.JSSourceMap;
pub const SMTPClient = api.SMTPClient;
};
const bun = @import("bun");

View File

@@ -948,7 +948,6 @@ pub const CommandLineReporter = struct {
this.printSummary();
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ this.jest.bail, if (this.jest.bail == 1) "" else "s" });
Output.flush();
this.writeJUnitReportIfNeeded();
Global.exit(1);
}
},
@@ -971,20 +970,6 @@ pub const CommandLineReporter = struct {
Output.printStartEnd(bun.start_time, std.time.nanoTimestamp());
}
/// Writes the JUnit reporter output file if a JUnit reporter is active and
/// an outfile path was configured. This must be called before any early exit
/// (e.g. bail) so that the report is not lost.
pub fn writeJUnitReportIfNeeded(this: *CommandLineReporter) void {
if (this.reporters.junit) |junit| {
if (this.jest.test_options.reporter_outfile) |outfile| {
if (junit.current_file.len > 0) {
junit.endTestSuite() catch {};
}
junit.writeToFile(outfile) catch {};
}
}
}
pub fn generateCodeCoverage(this: *CommandLineReporter, vm: *jsc.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime reporters: TestCommand.Reporters, comptime enable_ansi_colors: bool) !void {
if (comptime !reporters.text and !reporters.lcov) {
return;
@@ -1787,7 +1772,12 @@ pub const TestCommand = struct {
Output.prettyError("\n", .{});
Output.flush();
reporter.writeJUnitReportIfNeeded();
if (reporter.reporters.junit) |junit| {
if (junit.current_file.len > 0) {
junit.endTestSuite() catch {};
}
junit.writeToFile(ctx.test_options.reporter_outfile.?) catch {};
}
if (vm.hot_reload == .watch) {
vm.runWithAPILock(jsc.VirtualMachine, vm, runEventLoopForWatch);
@@ -1930,7 +1920,6 @@ pub const TestCommand = struct {
if (reporter.jest.bail == reporter.summary().fail) {
reporter.printSummary();
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" });
reporter.writeJUnitReportIfNeeded();
vm.exit_handler.exit_code = 1;
vm.is_shutting_down = true;

View File

@@ -23,6 +23,7 @@ var print_every_i: usize = 0;
// we always rewrite the entire HTTP request when write() returns EAGAIN
// so we can reuse this buffer
var shared_request_headers_buf: [256]picohttp.Header = undefined;
var shared_request_headers_overflow: ?[]picohttp.Header = null;
// this doesn't need to be stack memory because it is immediately cloned after use
var shared_response_headers_buf: [256]picohttp.Header = undefined;
@@ -605,7 +606,32 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
var header_entries = this.header_entries.slice();
const header_names = header_entries.items(.name);
const header_values = header_entries.items(.value);
var request_headers_buf = &shared_request_headers_buf;
// The maximum number of headers is the user-provided headers plus up to
// 6 extra headers that may be added below (Connection, User-Agent,
// Accept, Host, Accept-Encoding, Content-Length/Transfer-Encoding).
const max_headers = header_names.len + 6;
const static_buf_len = shared_request_headers_buf.len;
// Use the static buffer for the common case, dynamically allocate for overflow.
// The overflow buffer is kept around for reuse to avoid repeated allocations.
var request_headers_buf: []picohttp.Header = if (max_headers <= static_buf_len)
&shared_request_headers_buf
else blk: {
if (shared_request_headers_overflow) |overflow| {
if (overflow.len >= max_headers) {
break :blk overflow;
}
bun.default_allocator.free(overflow);
shared_request_headers_overflow = null;
}
const buf = bun.default_allocator.alloc(picohttp.Header, max_headers) catch
// On allocation failure, fall back to the static buffer and
// truncate headers rather than writing out of bounds.
break :blk @as([]picohttp.Header, &shared_request_headers_buf);
shared_request_headers_overflow = buf;
break :blk buf;
};
var override_accept_encoding = false;
var override_accept_header = false;
@@ -667,43 +693,32 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
else => {},
}
if (header_count >= request_headers_buf.len) break;
request_headers_buf[header_count] = .{
.name = name,
.value = this.headerStr(header_values[i]),
};
// header_name_hashes[header_count] = hash;
// // ensure duplicate headers come after each other
// if (header_count > 2) {
// var head_i: usize = header_count - 1;
// while (head_i > 0) : (head_i -= 1) {
// if (header_name_hashes[head_i] == header_name_hashes[header_count]) {
// std.mem.swap(picohttp.Header, &header_name_hashes[header_count], &header_name_hashes[head_i + 1]);
// std.mem.swap(u64, &request_headers_buf[header_count], &request_headers_buf[head_i + 1]);
// break;
// }
// }
// }
header_count += 1;
}
if (!override_connection_header and !this.flags.disable_keepalive) {
if (!override_connection_header and !this.flags.disable_keepalive and header_count < request_headers_buf.len) {
request_headers_buf[header_count] = connection_header;
header_count += 1;
}
if (!override_user_agent) {
if (!override_user_agent and header_count < request_headers_buf.len) {
request_headers_buf[header_count] = getUserAgentHeader();
header_count += 1;
}
if (!override_accept_header) {
if (!override_accept_header and header_count < request_headers_buf.len) {
request_headers_buf[header_count] = accept_header;
header_count += 1;
}
if (!override_host_header) {
if (!override_host_header and header_count < request_headers_buf.len) {
request_headers_buf[header_count] = .{
.name = host_header_name,
.value = this.url.host,
@@ -711,31 +726,33 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
header_count += 1;
}
if (!override_accept_encoding and !this.flags.disable_decompression) {
if (!override_accept_encoding and !this.flags.disable_decompression and header_count < request_headers_buf.len) {
request_headers_buf[header_count] = accept_encoding_header;
header_count += 1;
}
if (body_len > 0 or this.method.hasRequestBody()) {
if (this.flags.is_streaming_request_body) {
if (add_transfer_encoding and this.flags.upgrade_state == .none) {
request_headers_buf[header_count] = chunked_encoded_header;
if (header_count < request_headers_buf.len) {
if (body_len > 0 or this.method.hasRequestBody()) {
if (this.flags.is_streaming_request_body) {
if (add_transfer_encoding and this.flags.upgrade_state == .none) {
request_headers_buf[header_count] = chunked_encoded_header;
header_count += 1;
}
} else {
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = std.fmt.bufPrint(&this.request_content_len_buf, "{d}", .{body_len}) catch "0",
};
header_count += 1;
}
} else {
} else if (original_content_length) |content_length| {
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = std.fmt.bufPrint(&this.request_content_len_buf, "{d}", .{body_len}) catch "0",
.value = content_length,
};
header_count += 1;
}
} else if (original_content_length) |content_length| {
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = content_length,
};
header_count += 1;
}
return picohttp.Request{

View File

@@ -27,7 +27,6 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
ping_frame_bytes: [128 + 6]u8 = [_]u8{0} ** (128 + 6),
ping_len: u8 = 0,
ping_received: bool = false,
pong_received: bool = false,
close_received: bool = false,
close_frame_buffering: bool = false,
@@ -121,7 +120,6 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
this.clearReceiveBuffers(true);
this.clearSendBuffers(true);
this.ping_received = false;
this.pong_received = false;
this.ping_len = 0;
this.close_frame_buffering = false;
this.receive_pending_chunk_len = 0;
@@ -652,38 +650,14 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
if (data.len == 0) break;
},
.pong => {
if (!this.pong_received) {
if (receive_body_remain > 125) {
this.terminate(ErrorCode.invalid_control_frame);
terminated = true;
break;
}
this.ping_len = @truncate(receive_body_remain);
receive_body_remain = 0;
this.pong_received = true;
}
const pong_len = this.ping_len;
const pong_len = @min(data.len, @min(receive_body_remain, this.ping_frame_bytes.len));
if (data.len > 0) {
const total_received = @min(pong_len, receive_body_remain + data.len);
const slice = this.ping_frame_bytes[6..][receive_body_remain..total_received];
@memcpy(slice, data[0..slice.len]);
receive_body_remain = total_received;
data = data[slice.len..];
}
const pending_body = pong_len - receive_body_remain;
if (pending_body > 0) {
// wait for more data - pong payload is fragmented across TCP segments
break;
}
const pong_data = this.ping_frame_bytes[6..][0..pong_len];
this.dispatchData(pong_data, .Pong);
this.dispatchData(data[0..pong_len], .Pong);
data = data[pong_len..];
receive_state = .need_header;
receive_body_remain = 0;
receiving_type = last_receive_data_type;
this.pong_received = false;
if (data.len == 0) break;
},

View File

@@ -291,32 +291,25 @@ pub const Parser = struct {
}
},
else => {
switch (bun.strings.utf8ByteSequenceLength(c)) {
0, 1 => try unesc.appendSlice(&[_]u8{ '\\', c }),
2 => if (val.len - i >= 2) {
try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1] });
i += 1;
} else {
try unesc.appendSlice(&[_]u8{ '\\', c });
try unesc.appendSlice(switch (bun.strings.utf8ByteSequenceLength(c)) {
1 => brk: {
break :brk &[_]u8{ '\\', c };
},
3 => if (val.len - i >= 3) {
try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1], val[i + 2] });
i += 2;
} else {
try unesc.append('\\');
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
2 => brk: {
defer i += 1;
break :brk &[_]u8{ '\\', c, val[i + 1] };
},
4 => if (val.len - i >= 4) {
try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1], val[i + 2], val[i + 3] });
i += 3;
} else {
try unesc.append('\\');
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
3 => brk: {
defer i += 2;
break :brk &[_]u8{ '\\', c, val[i + 1], val[i + 2] };
},
4 => brk: {
defer i += 3;
break :brk &[_]u8{ '\\', c, val[i + 1], val[i + 2], val[i + 3] };
},
// this means invalid utf8
else => unreachable,
}
});
},
}
@@ -349,30 +342,25 @@ pub const Parser = struct {
try unesc.append('.');
}
},
else => switch (bun.strings.utf8ByteSequenceLength(c)) {
0, 1 => try unesc.append(c),
2 => if (val.len - i >= 2) {
try unesc.appendSlice(&[_]u8{ c, val[i + 1] });
i += 1;
} else {
try unesc.append(c);
else => try unesc.appendSlice(switch (bun.strings.utf8ByteSequenceLength(c)) {
1 => brk: {
break :brk &[_]u8{c};
},
3 => if (val.len - i >= 3) {
try unesc.appendSlice(&[_]u8{ c, val[i + 1], val[i + 2] });
i += 2;
} else {
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
2 => brk: {
defer i += 1;
break :brk &[_]u8{ c, val[i + 1] };
},
4 => if (val.len - i >= 4) {
try unesc.appendSlice(&[_]u8{ c, val[i + 1], val[i + 2], val[i + 3] });
i += 3;
} else {
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
3 => brk: {
defer i += 2;
break :brk &[_]u8{ c, val[i + 1], val[i + 2] };
},
4 => brk: {
defer i += 3;
break :brk &[_]u8{ c, val[i + 1], val[i + 2], val[i + 3] };
},
// this means invalid utf8
else => unreachable,
},
}),
}
}

View File

@@ -214,14 +214,6 @@ export const lsanDoLeakCheck = $newCppFunction("InternalForTesting.cpp", "jsFunc
export const getEventLoopStats: () => { activeTasks: number; concurrentRef: number; numPolls: number } =
$newZigFunction("event_loop.zig", "getActiveTasks", 0);
export const smtpInternals = {
isPlainText: $newZigFunction("mime.zig", "TestingAPIs.jsIsPlainText", 1),
hasLongerLines: $newZigFunction("mime.zig", "TestingAPIs.jsHasLongerLines", 2),
encodeWord: $newZigFunction("mime.zig", "TestingAPIs.jsEncodeWord", 2),
encodeQP: $newZigFunction("mime.zig", "TestingAPIs.jsEncodeQP", 1),
foldHeader: $newZigFunction("mime.zig", "TestingAPIs.jsFoldHeader", 1),
};
export const hostedGitInfo = {
parseUrl: $newZigFunction("hosted_git_info.zig", "TestingAPIs.jsParseUrl", 1),
fromUrl: $newZigFunction("hosted_git_info.zig", "TestingAPIs.jsFromUrl", 1),

View File

@@ -460,13 +460,13 @@ pub const Archiver = struct {
if (comptime Environment.isWindows) {
try bun.MakePath.makePath(u16, dir, path);
} else {
std.posix.mkdiratZ(dir_fd, path, @intCast(mode)) catch |err| {
std.posix.mkdiratZ(dir_fd, pathname, @intCast(mode)) catch |err| {
// It's possible for some tarballs to return a directory twice, with and
// without `./` in the beginning. So if it already exists, continue to the
// next entry.
if (err == error.PathAlreadyExists or err == error.NotDir) continue;
bun.makePath(dir, std.fs.path.dirname(path_slice) orelse return err) catch {};
std.posix.mkdiratZ(dir_fd, path, 0o777) catch {};
std.posix.mkdiratZ(dir_fd, pathname, 0o777) catch {};
};
}
},

View File

@@ -221,11 +221,7 @@ pub const S3Credentials = struct {
defer str.deref();
if (str.tag != .Empty and str.tag != .Dead) {
new_credentials._contentDispositionSlice = str.toUTF8(bun.default_allocator);
const slice = new_credentials._contentDispositionSlice.?.slice();
if (containsNewlineOrCR(slice)) {
return globalObject.throwInvalidArguments("contentDisposition must not contain newline characters (CR/LF)", .{});
}
new_credentials.content_disposition = slice;
new_credentials.content_disposition = new_credentials._contentDispositionSlice.?.slice();
}
} else {
return globalObject.throwInvalidArgumentTypeValue("contentDisposition", "string", js_value);
@@ -240,11 +236,7 @@ pub const S3Credentials = struct {
defer str.deref();
if (str.tag != .Empty and str.tag != .Dead) {
new_credentials._contentTypeSlice = str.toUTF8(bun.default_allocator);
const slice = new_credentials._contentTypeSlice.?.slice();
if (containsNewlineOrCR(slice)) {
return globalObject.throwInvalidArguments("type must not contain newline characters (CR/LF)", .{});
}
new_credentials.content_type = slice;
new_credentials.content_type = new_credentials._contentTypeSlice.?.slice();
}
} else {
return globalObject.throwInvalidArgumentTypeValue("type", "string", js_value);
@@ -259,11 +251,7 @@ pub const S3Credentials = struct {
defer str.deref();
if (str.tag != .Empty and str.tag != .Dead) {
new_credentials._contentEncodingSlice = str.toUTF8(bun.default_allocator);
const slice = new_credentials._contentEncodingSlice.?.slice();
if (containsNewlineOrCR(slice)) {
return globalObject.throwInvalidArguments("contentEncoding must not contain newline characters (CR/LF)", .{});
}
new_credentials.content_encoding = slice;
new_credentials.content_encoding = new_credentials._contentEncodingSlice.?.slice();
}
} else {
return globalObject.throwInvalidArgumentTypeValue("contentEncoding", "string", js_value);
@@ -1162,12 +1150,6 @@ const CanonicalRequest = struct {
}
};
/// Returns true if the given slice contains any CR (\r) or LF (\n) characters,
/// which would allow HTTP header injection if used in a header value.
fn containsNewlineOrCR(value: []const u8) bool {
return std.mem.indexOfAny(u8, value, "\r\n") != null;
}
const std = @import("std");
const ACL = @import("./acl.zig").ACL;
const MultiPartUploadOptions = @import("./multipart_options.zig").MultiPartUploadOptions;

View File

@@ -1154,7 +1154,7 @@ pub const Interpreter = struct {
_ = callframe; // autofix
if (this.setupIOBeforeRun().asErr()) |e| {
defer this.#derefRootShellAndIOIfNeeded(true);
defer this.#deinitFromExec();
const shellerr = bun.shell.ShellErr.newSys(e);
return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop });
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,443 +0,0 @@
/// RFC 5322 email address parser (ported from nodemailer/lib/addressparser).
/// Parses structured email addresses from address field strings.
const MAX_NESTED_GROUP_DEPTH = 50;
pub const Address = struct {
name: []const u8 = "",
address: []const u8 = "",
};
pub const Group = struct {
name: []const u8 = "",
members: []Address = &.{},
};
pub const ParsedAddress = union(enum) {
address: Address,
group: Group,
};
/// Parse an email address string into structured address objects.
pub fn parse(alloc: std.mem.Allocator, input: []const u8) ![]ParsedAddress {
return parseWithDepth(alloc, input, 0);
}
const ParseError = std.mem.Allocator.Error || error{ JSError, JSTerminated };
fn parseWithDepth(alloc: std.mem.Allocator, input: []const u8, depth: u32) ParseError![]ParsedAddress {
if (depth > MAX_NESTED_GROUP_DEPTH) return try alloc.alloc(ParsedAddress, 0);
if (input.len == 0) return try alloc.alloc(ParsedAddress, 0);
const tokens = try tokenize(alloc, input);
defer alloc.free(tokens);
// Split by , and ; delimiters
var sfb = std.heap.stackFallback(@sizeOf(ParsedAddress) * 32, alloc);
const sfb_alloc = sfb.get();
var results = std.ArrayListUnmanaged(ParsedAddress){};
defer results.deinit(sfb_alloc);
var group_start: usize = 0;
for (tokens, 0..) |tok, i| {
if (tok.kind == .operator and tok.value.len == 1 and (tok.value[0] == ',' or tok.value[0] == ';')) {
if (i > group_start) {
const parsed = try handleAddress(alloc, tokens[group_start..i], depth);
defer alloc.free(parsed);
for (parsed) |p| try results.append(sfb_alloc, p);
}
group_start = i + 1;
}
}
if (group_start < tokens.len) {
const parsed = try handleAddress(alloc, tokens[group_start..], depth);
defer alloc.free(parsed);
for (parsed) |p| try results.append(sfb_alloc, p);
}
const result = try alloc.alloc(ParsedAddress, results.items.len);
@memcpy(result, results.items);
return result;
}
/// Extract just the email address from a potentially complex address string.
pub fn extractEmail(addr: []const u8) []const u8 {
if (std.mem.lastIndexOfScalar(u8, addr, '<')) |start| {
if (std.mem.indexOfScalarPos(u8, addr, start, '>')) |end| {
return addr[start + 1 .. end];
}
}
return std.mem.trim(u8, addr, " \t");
}
// ============================================================================
// Tokenizer
// ============================================================================
const TokenKind = enum { operator, text };
const Token = struct { kind: TokenKind, value: []const u8, no_break: bool = false, was_quoted: bool = false };
fn tokenize(alloc: std.mem.Allocator, input: []const u8) ![]Token {
// Pre-allocate worst case
var tokens = try alloc.alloc(Token, input.len);
var token_count: usize = 0;
var val = bun.MutableString.initEmpty(alloc);
var op_expecting: u8 = 0;
var escaped = false;
var in_text = false;
var i: usize = 0;
while (i < input.len) : (i += 1) {
const chr = input[i];
const next: u8 = if (i + 1 < input.len) input[i + 1] else 0;
if (escaped) {
escaped = false;
try val.writer().writeByte(chr);
continue;
}
// Closing operator match
if (op_expecting != 0 and chr == op_expecting) {
// Flush text
const trimmed = std.mem.trim(u8, val.slice(), " \t");
if (trimmed.len > 0) {
tokens[token_count] = .{ .kind = .text, .value = try alloc.dupe(u8, trimmed), .was_quoted = op_expecting == '"' };
token_count += 1;
}
val.list.clearRetainingCapacity();
const no_break = next != 0 and next != ' ' and next != '\t' and next != ',' and next != ';';
tokens[token_count] = .{ .kind = .operator, .value = try alloc.dupe(u8, &[_]u8{chr}), .no_break = no_break };
token_count += 1;
op_expecting = 0;
in_text = false;
continue;
}
// Inside operator pair
if (op_expecting != 0) {
if (op_expecting == '"' and chr == '\\') {
escaped = true;
continue;
}
if (chr == '\r') continue;
try val.writer().writeByte(if (chr == '\n') ' ' else chr);
continue;
}
// Opening operators
const expecting: u8 = switch (chr) {
'"' => '"',
'(' => ')',
'<' => '>',
',' => 0,
':' => ';',
';' => 0,
else => 255,
};
if (expecting != 255) {
// Flush current text
const trimmed = std.mem.trim(u8, val.slice(), " \t");
if (trimmed.len > 0) {
tokens[token_count] = .{ .kind = .text, .value = try alloc.dupe(u8, trimmed) };
token_count += 1;
}
val.list.clearRetainingCapacity();
in_text = false;
tokens[token_count] = .{ .kind = .operator, .value = try alloc.dupe(u8, &[_]u8{chr}) };
token_count += 1;
op_expecting = expecting;
continue;
}
// Regular text
if (!in_text) in_text = true;
if (chr == '\n') {
try val.writer().writeByte(' ');
} else if (chr != '\r' and (chr >= 0x21 or chr == ' ' or chr == '\t')) {
try val.writer().writeByte(chr);
}
}
// Flush remaining
const trimmed = std.mem.trim(u8, val.slice(), " \t");
if (trimmed.len > 0) {
tokens[token_count] = .{ .kind = .text, .value = try alloc.dupe(u8, trimmed) };
token_count += 1;
}
val.deinit();
// Shrink to actual size
if (token_count < tokens.len) {
tokens = try alloc.realloc(tokens, token_count);
}
return tokens[0..token_count];
}
// ============================================================================
// Address handler
// ============================================================================
fn handleAddress(alloc: std.mem.Allocator, tokens: []const Token, depth: u32) ParseError![]ParsedAddress {
var addr_buf: [64][]const u8 = undefined;
var addr_count: usize = 0;
var text_buf: [64][]const u8 = undefined;
var text_count: usize = 0;
var text_quoted_buf: [64]bool = undefined;
var comment_buf: [64][]const u8 = undefined;
var comment_count: usize = 0;
var group_buf: [256][]const u8 = undefined;
var group_count: usize = 0;
var is_group = false;
var state: enum { text, address, comment, group } = .text;
var in_quotes = false;
for (tokens) |tok| {
if (tok.kind == .operator and tok.value.len == 1) {
switch (tok.value[0]) {
'<' => {
state = .address;
in_quotes = false;
continue;
},
'(' => {
state = .comment;
in_quotes = false;
continue;
},
':' => {
state = .group;
is_group = true;
in_quotes = false;
continue;
},
'"' => {
in_quotes = !in_quotes;
state = .text;
continue;
},
'>', ')' => {
state = .text;
in_quotes = false;
continue;
},
else => {
state = .text;
in_quotes = false;
continue;
},
}
}
if (tok.kind == .operator) continue;
switch (state) {
.address => if (addr_count < addr_buf.len) {
addr_buf[addr_count] = tok.value;
addr_count += 1;
},
.comment => if (comment_count < comment_buf.len) {
comment_buf[comment_count] = tok.value;
comment_count += 1;
},
.group => if (group_count < group_buf.len) {
group_buf[group_count] = tok.value;
group_count += 1;
},
.text => if (text_count < text_buf.len) {
text_buf[text_count] = tok.value;
text_quoted_buf[text_count] = in_quotes or tok.was_quoted;
text_count += 1;
},
}
}
// If no text but has comments, use comments as text
if (text_count == 0 and comment_count > 0) {
for (comment_buf[0..comment_count], 0..) |c, ci| {
if (text_count < text_buf.len) {
text_buf[text_count] = c;
text_quoted_buf[text_count] = false;
text_count += 1;
}
_ = ci;
}
}
var result = try alloc.alloc(ParsedAddress, 1);
var result_count: usize = 0;
if (is_group) {
const group_str = try std.mem.join(alloc, ",", group_buf[0..group_count]);
defer alloc.free(group_str);
const members_parsed = try parseWithDepth(alloc, group_str, depth + 1);
defer alloc.free(members_parsed);
// Flatten and count
var member_count: usize = 0;
for (members_parsed) |m| {
switch (m) {
.address => member_count += 1,
.group => |g| member_count += g.members.len,
}
}
const members = try alloc.alloc(Address, member_count);
var mi: usize = 0;
for (members_parsed) |m| {
switch (m) {
.address => |a| {
members[mi] = a;
mi += 1;
},
.group => |g| {
for (g.members) |gm| {
members[mi] = gm;
mi += 1;
}
},
}
}
const name = try std.mem.join(alloc, " ", text_buf[0..text_count]);
result[0] = .{ .group = .{ .name = name, .members = members } };
result_count = 1;
} else {
var address: []const u8 = "";
if (addr_count > 0) {
// Fix Bug 2: strip content before last '<' in address
var raw_addr = addr_buf[0];
if (std.mem.lastIndexOfScalar(u8, raw_addr, '<')) |lt| {
raw_addr = std.mem.trim(u8, raw_addr[lt + 1 ..], " \t");
}
address = raw_addr;
} else if (text_count > 0) {
// Fix Bug 3: if a quoted text is followed by @domain text, concatenate them
if (text_count >= 2) {
var ci: usize = 0;
while (ci + 1 < text_count) : (ci += 1) {
if (text_quoted_buf[ci] and !text_quoted_buf[ci + 1] and
text_buf[ci + 1].len > 0 and text_buf[ci + 1][0] == '@')
{
// Concatenate: "quoted" + "@domain" = "quoted@domain"
const joined = try std.fmt.allocPrint(alloc, "{s}{s}", .{ text_buf[ci], text_buf[ci + 1] });
address = joined;
// Remove both tokens
var k2 = ci;
while (k2 + 2 < text_count) : (k2 += 1) {
text_buf[k2] = text_buf[k2 + 2];
text_quoted_buf[k2] = text_quoted_buf[k2 + 2];
}
text_count -= 2;
break;
}
}
}
// Look for email in non-quoted text (security: don't extract from quoted strings)
// Skip if already found address via quoted+domain concatenation above
var idx: usize = if (address.len > 0) 0 else text_count;
while (idx > 0) {
idx -= 1;
if (!text_quoted_buf[idx] and std.mem.indexOfScalar(u8, text_buf[idx], '@') != null) {
// Fix Bug 1: if the token contains spaces, extract just the email part
const token = text_buf[idx];
if (std.mem.indexOfAny(u8, token, " \t") != null) {
// Split: find the word containing @
var words_iter = std.mem.splitAny(u8, token, " \t");
var remaining_parts: [64][]const u8 = undefined;
var remaining_count: usize = 0;
var found_email: ?[]const u8 = null;
while (words_iter.next()) |word| {
if (found_email == null and std.mem.indexOfScalar(u8, word, '@') != null) {
found_email = word;
} else if (word.len > 0) {
if (remaining_count < remaining_parts.len) {
remaining_parts[remaining_count] = word;
remaining_count += 1;
}
}
}
if (found_email) |email| {
address = email;
// Replace the original token with the remaining text parts
if (remaining_count > 0) {
text_buf[idx] = try std.mem.join(alloc, " ", remaining_parts[0..remaining_count]);
text_quoted_buf[idx] = false;
} else {
var j = idx;
while (j + 1 < text_count) : (j += 1) {
text_buf[j] = text_buf[j + 1];
text_quoted_buf[j] = text_quoted_buf[j + 1];
}
text_count -= 1;
}
break;
}
} else {
address = token;
var j = idx;
while (j + 1 < text_count) : (j += 1) {
text_buf[j] = text_buf[j + 1];
text_quoted_buf[j] = text_quoted_buf[j + 1];
}
text_count -= 1;
break;
}
}
}
// Fix Bug 4: if still no address and ALL text is quoted, check if it contains @
if (address.len == 0) {
var all_quoted = true;
for (text_quoted_buf[0..text_count]) |q| {
if (!q) {
all_quoted = false;
break;
}
}
if (all_quoted and text_count > 0) {
for (text_buf[0..text_count], 0..) |t, ti| {
if (std.mem.indexOfScalar(u8, t, '@') != null) {
address = t;
var j = ti;
while (j + 1 < text_count) : (j += 1) {
text_buf[j] = text_buf[j + 1];
text_quoted_buf[j] = text_quoted_buf[j + 1];
}
text_count -= 1;
break;
}
}
}
}
}
var name = try std.mem.join(alloc, " ", text_buf[0..text_count]);
// If no display name found but we have comments, use the first comment
if (name.len == 0 and comment_count > 0) {
alloc.free(name);
name = try alloc.dupe(u8, comment_buf[0]);
}
if (std.mem.eql(u8, name, address)) {
if (std.mem.indexOfScalar(u8, address, '@') != null) {
result[0] = .{ .address = .{ .name = "", .address = address } };
} else {
result[0] = .{ .address = .{ .name = name, .address = "" } };
}
} else {
result[0] = .{ .address = .{ .name = name, .address = address } };
}
result_count = 1;
}
return result[0..result_count];
}
const bun = @import("bun");
const std = @import("std");

View File

@@ -1,263 +0,0 @@
/// DKIM (DomainKeys Identified Mail) signing implementation using BoringSSL.
/// Implements RFC 6376 with relaxed/relaxed canonicalization and rsa-sha256.
const BoringSSL = bun.BoringSSL;
pub const DKIMConfig = struct {
domain_name: []const u8,
key_selector: []const u8,
private_key_pem: []const u8,
};
/// Sign a complete RFC822 message with DKIM.
/// Returns the DKIM-Signature header value (without the "DKIM-Signature: " prefix).
/// The caller should prepend this header to the message.
pub fn sign(alloc: std.mem.Allocator, message: []const u8, config: DKIMConfig) ![]const u8 {
// 1. Split message into headers and body
const sep = findHeaderBodySeparator(message);
const headers_raw = message[0..sep.pos];
const body_start = @min(sep.pos + sep.len, message.len);
const body_raw = if (body_start < message.len) message[body_start..] else "";
// 2. Canonicalize body (relaxed)
var body_hash_ctx: bun.sha.SHA256 = bun.sha.SHA256.init();
const canonical_body = canonicalizeBodyRelaxed(alloc, body_raw) catch return error.OutOfMemory;
defer alloc.free(canonical_body);
body_hash_ctx.update(canonical_body);
var body_hash: [bun.sha.SHA256.digest]u8 = undefined;
body_hash_ctx.final(&body_hash);
// Base64 encode body hash
var bh_buf: [128]u8 = undefined;
const bh_len = bun.base64.encode(&bh_buf, &body_hash);
const body_hash_b64 = bh_buf[0..bh_len];
// 3. Determine which headers to sign
const signed_headers = "from:to:subject:date:message-id:mime-version";
// 4. Build DKIM-Signature header (without b= value yet)
var sig_header = bun.MutableString.initEmpty(alloc);
defer sig_header.deinit();
const sig_writer = sig_header.writer();
try sig_writer.print("v=1; a=rsa-sha256; c=relaxed/relaxed; d={s}; s={s}; h={s}; bh={s}; b=", .{
config.domain_name,
config.key_selector,
signed_headers,
body_hash_b64,
});
// 5. Canonicalize headers (relaxed) for signing
var header_canon = bun.MutableString.initEmpty(alloc);
defer header_canon.deinit();
const hc_writer = header_canon.writer();
// Add each signed header in canonicalized form
var header_iter = std.mem.splitSequence(u8, signed_headers, ":");
while (header_iter.next()) |header_name| {
if (findHeader(headers_raw, header_name)) |header_value| {
// Relaxed header canonicalization: lowercase name, unfold, compress whitespace
try hc_writer.writeAll(header_name);
try hc_writer.writeAll(":");
try writeCanonicalHeaderValue(hc_writer, header_value);
try hc_writer.writeAll("\r\n");
}
}
// Add the DKIM-Signature header itself (without trailing \r\n, and with empty b=)
try hc_writer.writeAll("dkim-signature:");
try writeCanonicalHeaderValue(hc_writer, sig_header.slice());
// 6. RSA-SHA256 sign the canonical header hash
const signature = try rsaSha256Sign(alloc, header_canon.slice(), config.private_key_pem);
defer alloc.free(signature);
// Base64 encode signature
const sig_b64_len = bun.base64.encodeLen(signature);
const sig_b64 = try alloc.alloc(u8, sig_b64_len);
defer alloc.free(sig_b64);
const actual_sig_len = bun.base64.encode(sig_b64, signature);
// 7. Build complete DKIM-Signature header value
var result = bun.MutableString.initEmpty(alloc);
const rw = result.writer();
try rw.writeAll(sig_header.slice());
try rw.writeAll(sig_b64[0..actual_sig_len]);
return result.toOwnedSlice();
}
/// Prepend DKIM-Signature header to a message.
pub fn signMessage(alloc: std.mem.Allocator, message: []const u8, config: DKIMConfig) ![]const u8 {
const dkim_value = try sign(alloc, message, config);
defer alloc.free(dkim_value);
var result = bun.MutableString.initEmpty(alloc);
const w = result.writer();
try w.writeAll("DKIM-Signature: ");
try w.writeAll(dkim_value);
try w.writeAll("\r\n");
try w.writeAll(message);
return result.toOwnedSlice();
}
// ============================================================================
// Internal helpers
// ============================================================================
const SepResult = struct { pos: usize, len: usize };
fn findHeaderBodySeparator(message: []const u8) SepResult {
if (std.mem.indexOf(u8, message, "\r\n\r\n")) |pos| return .{ .pos = pos, .len = 4 };
if (std.mem.indexOf(u8, message, "\n\n")) |pos| return .{ .pos = pos, .len = 2 };
return .{ .pos = message.len, .len = 0 };
}
fn findHeader(headers: []const u8, name: []const u8) ?[]const u8 {
var pos: usize = 0;
while (pos < headers.len) {
// Handle both \r\n and \n line endings
var line_end = std.mem.indexOfPos(u8, headers, pos, "\n") orelse headers.len;
var skip: usize = 1; // skip \n
if (line_end > pos and headers[line_end - 1] == '\r') {
line_end -= 1; // exclude \r
skip = 2; // skip \r\n
}
const line = headers[pos..line_end];
// Check if this line starts with the header name (case-insensitive)
if (line.len > name.len and line[name.len] == ':') {
if (std.ascii.eqlIgnoreCase(line[0..name.len], name)) {
// Return the full header value including any continuation lines.
// Continuation lines start with WSP (space or tab).
var val_end = @min(line_end + skip, headers.len);
while (val_end < headers.len and (headers[val_end] == ' ' or headers[val_end] == '\t')) {
// This is a continuation line — extend val_end to its end
val_end = std.mem.indexOfPos(u8, headers, val_end, "\n") orelse headers.len;
if (val_end < headers.len) val_end += 1; // skip \n
}
var val_start = name.len + 1;
while (val_start < line.len and line[val_start] == ' ') : (val_start += 1) {}
return headers[pos + val_start .. val_end];
}
}
pos = @min(line_end + skip, headers.len);
}
return null;
}
fn writeCanonicalHeaderValue(writer: anytype, value: []const u8) !void {
// Relaxed header canonicalization (RFC 6376 §3.4.2):
// - Unfold header continuation lines
// - Reduce all sequences of WSP to a single SP
// - Remove leading and trailing WSP
var in_wsp = true; // Start true to skip leading WSP
var has_content = false;
for (value) |c| {
if (c == '\r' or c == '\n') continue;
if (c == ' ' or c == '\t') {
if (has_content) in_wsp = true;
} else {
if (in_wsp and has_content) {
try writer.writeByte(' ');
}
in_wsp = false;
has_content = true;
try writer.writeByte(c);
}
}
}
fn canonicalizeBodyRelaxed(alloc: std.mem.Allocator, body: []const u8) ![]const u8 {
var result = bun.MutableString.initEmpty(alloc);
const writer = result.writer();
// Relaxed body canonicalization:
// - Reduce WSP sequences within lines to single SP
// - Remove all trailing WSP from lines
// - Remove all empty lines at end of body
// - Ensure body ends with \r\n (if non-empty)
var line_start: usize = 0;
while (line_start < body.len) {
const line_end = std.mem.indexOfPos(u8, body, line_start, "\r\n") orelse body.len;
const line = body[line_start..line_end];
// Write canonicalized line: compress WSP, trim trailing WSP
var in_wsp = false;
var written: usize = 0;
for (line) |c| {
if (c == ' ' or c == '\t') {
in_wsp = true;
} else {
if (in_wsp and written > 0) {
try writer.writeByte(' ');
written += 1;
}
in_wsp = false;
try writer.writeByte(c);
written += 1;
}
}
try writer.writeAll("\r\n");
line_start = if (line_end + 2 <= body.len) line_end + 2 else body.len;
}
// Remove trailing empty lines
var slice = result.slice();
while (slice.len >= 4 and std.mem.eql(u8, slice[slice.len - 4 ..], "\r\n\r\n")) {
result.list.items.len -= 2;
slice = result.slice();
}
// Ensure ends with \r\n
if (slice.len == 0 or !std.mem.endsWith(u8, slice, "\r\n")) {
try writer.writeAll("\r\n");
}
return result.toOwnedSlice();
}
fn rsaSha256Sign(alloc: std.mem.Allocator, data: []const u8, private_key_pem: []const u8) ![]const u8 {
const c = BoringSSL.c;
// Load private key from PEM
const bio = c.BIO_new_mem_buf(private_key_pem.ptr, @intCast(private_key_pem.len)) orelse return error.BIOCreateFailed;
defer _ = c.BIO_free(bio);
var pkey: [*c]c.EVP_PKEY = null;
pkey = c.PEM_read_bio_PrivateKey(bio, &pkey, null, null);
if (pkey == null) return error.PrivateKeyParseFailed;
defer c.EVP_PKEY_free(pkey);
// Create signing context
const md_ctx = c.EVP_MD_CTX_new() orelse return error.MDContextCreateFailed;
defer c.EVP_MD_CTX_free(md_ctx);
if (c.EVP_DigestSignInit(md_ctx, null, c.EVP_sha256(), null, pkey) != 1) {
return error.DigestSignInitFailed;
}
if (c.EVP_DigestSignUpdate(md_ctx, data.ptr, data.len) != 1) {
return error.DigestSignUpdateFailed;
}
// Get signature length
var sig_len: usize = 0;
if (c.EVP_DigestSignFinal(md_ctx, null, &sig_len) != 1) {
return error.DigestSignFinalFailed;
}
// Sign
const sig_buf = try alloc.alloc(u8, sig_len);
errdefer alloc.free(sig_buf);
if (c.EVP_DigestSignFinal(md_ctx, sig_buf.ptr, &sig_len) != 1) {
return error.DigestSignFinalFailed;
}
return sig_buf[0..sig_len];
}
const bun = @import("bun");
const std = @import("std");

View File

@@ -1,976 +0,0 @@
/// MIME message builder for SMTP.
/// Constructs RFC 5322 compliant email messages with multipart support,
/// quoted-printable encoding, base64 attachments, and RFC 2047 header encoding.
const MimeType = bun.http.MimeType;
/// Options for message building.
pub const BuildOptions = struct {
message_id_hostname: []const u8 = "bun",
keep_bcc: bool = false,
disable_file_access: bool = false,
};
pub fn buildMessageWithOptions(alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue, opts: BuildOptions) ![]const u8 {
var message = bun.MutableString.initEmpty(alloc);
const writer = message.writer();
// Standard headers
try writeStringField(writer, alloc, globalObject, msg, "from", "From");
try writeAddressField(writer, alloc, globalObject, msg, "to", "To");
try writeAddressField(writer, alloc, globalObject, msg, "cc", "Cc");
// BCC: only include if keepBcc is true (default: strip from headers per RFC 5321)
if (opts.keep_bcc) {
try writeAddressField(writer, alloc, globalObject, msg, "bcc", "Bcc");
}
try writeStringField(writer, alloc, globalObject, msg, "replyTo", "Reply-To");
try writeStringField(writer, alloc, globalObject, msg, "inReplyTo", "In-Reply-To");
try writeStringField(writer, alloc, globalObject, msg, "references", "References");
// Subject - needs RFC 2047 encoding for non-ASCII
if (try msg.getTruthy(globalObject, "subject")) |v| {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const encoded = try encodeHeaderValue(alloc, utf8.slice());
defer alloc.free(encoded);
try writeFoldedHeaderLine(writer, alloc, "Subject", encoded);
}
// Date header (RFC 2822 format)
try writeDateHeader(writer);
// Message-ID using bun.csprng for cryptographic randomness
var random_bytes: [16]u8 = undefined;
bun.csprng(&random_bytes);
try writer.print("Message-ID: <{s}@{s}>\r\n", .{ std.fmt.bytesToHex(random_bytes, .lower), opts.message_id_hostname });
try writer.writeAll("MIME-Version: 1.0\r\n");
try writer.writeAll("X-Mailer: Bun\r\n");
// Priority headers: priority can be "high", "normal", "low"
if (try msg.getTruthy(globalObject, "priority")) |v| {
if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const p = utf8.slice();
if (bun.strings.eqlComptime(p, "high")) {
try writer.writeAll("X-Priority: 1 (Highest)\r\nX-MSMail-Priority: High\r\nImportance: High\r\n");
} else if (bun.strings.eqlComptime(p, "low")) {
try writer.writeAll("X-Priority: 5 (Lowest)\r\nX-MSMail-Priority: Low\r\nImportance: Low\r\n");
}
}
}
// List-* headers (for mailing lists)
if (try msg.getTruthy(globalObject, "list")) |list_obj| {
if (list_obj.isObject()) {
try writeListHeader(writer, alloc, globalObject, list_obj, "unsubscribe", "List-Unsubscribe");
try writeListHeader(writer, alloc, globalObject, list_obj, "subscribe", "List-Subscribe");
try writeListHeader(writer, alloc, globalObject, list_obj, "help", "List-Help");
try writeListHeader(writer, alloc, globalObject, list_obj, "post", "List-Post");
if (try list_obj.getTruthy(globalObject, "id")) |v| {
if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const u = s.toUTF8WithoutRef(alloc);
defer u.deinit();
const clean_id = try sanitizeHeaderValue(alloc, u.slice());
defer alloc.free(clean_id);
try writer.print("List-Id: {s}\r\n", .{clean_id});
}
}
}
}
// Custom headers
try writeCustomHeaders(writer, alloc, globalObject, msg);
// Determine content parts
const has_html = (try msg.getTruthy(globalObject, "html")) != null;
const has_text = (try msg.getTruthy(globalObject, "text")) != null;
const has_attachments = if (try msg.getTruthy(globalObject, "attachments")) |a| a.isArray() else false;
const has_ical = (try msg.getTruthy(globalObject, "icalEvent")) != null;
// Generate boundary using csprng
var boundary_bytes: [12]u8 = undefined;
bun.csprng(&boundary_bytes);
var boundary_buf: [64]u8 = undefined;
const boundary = std.fmt.bufPrint(&boundary_buf, "----=_Bun_{s}", .{std.fmt.bytesToHex(boundary_bytes, .lower)}) catch "----=_Bun_fallback";
if (has_attachments) {
try writer.print("Content-Type: multipart/mixed;\r\n boundary=\"{s}\"\r\n\r\n", .{boundary});
if (has_html and has_text) {
var inner_boundary_bytes: [12]u8 = undefined;
bun.csprng(&inner_boundary_bytes);
var inner_boundary_buf: [64]u8 = undefined;
const inner_boundary = std.fmt.bufPrint(&inner_boundary_buf, "----=_Alt_{s}", .{std.fmt.bytesToHex(inner_boundary_bytes, .lower)}) catch "----=_Alt_fallback";
try writer.print("--{s}\r\nContent-Type: multipart/alternative;\r\n boundary=\"{s}\"\r\n\r\n", .{ boundary, inner_boundary });
try writeTextPart(writer, alloc, globalObject, msg, inner_boundary);
try writeHtmlPart(writer, alloc, globalObject, msg, inner_boundary);
try writer.print("--{s}--\r\n\r\n", .{inner_boundary});
} else if (has_html) {
try writer.print("--{s}\r\n", .{boundary});
try writeInlineHtml(writer, alloc, globalObject, msg);
} else if (has_text) {
try writer.print("--{s}\r\n", .{boundary});
try writeInlineText(writer, alloc, globalObject, msg);
}
if (try msg.getTruthy(globalObject, "attachments")) |att_array| {
var iter = try att_array.arrayIterator(globalObject);
while (try iter.next()) |att| {
if (att.isObject()) {
try writeAttachmentWithOpts(writer, alloc, globalObject, att, boundary, opts.disable_file_access);
}
}
}
try writer.print("--{s}--\r\n", .{boundary});
} else if ((has_html and has_text) or has_ical) {
try writer.print("Content-Type: multipart/alternative;\r\n boundary=\"{s}\"\r\n\r\n", .{boundary});
if (has_text) try writeTextPart(writer, alloc, globalObject, msg, boundary);
if (has_html) try writeHtmlPart(writer, alloc, globalObject, msg, boundary);
if (has_ical) try writeIcalPart(writer, alloc, globalObject, msg, boundary);
try writer.print("--{s}--\r\n", .{boundary});
} else if (has_html) {
try writeInlineHtml(writer, alloc, globalObject, msg);
} else {
try writeInlineText(writer, alloc, globalObject, msg);
}
return message.toOwnedSlice();
}
/// Extract bare email address from "Display Name <email@host>" format.
pub const extractEmail = @import("./address_parser.zig").extractEmail;
// ============================================================================
// Header helpers
// ============================================================================
/// Check if a string contains only printable ASCII (plus TAB, CR, LF).
pub fn isPlainText(value: []const u8) bool {
for (value) |c| {
if (c > 126 or (c < 32 and c != '\t' and c != '\r' and c != '\n')) return false;
}
return true;
}
/// Check if any line in the string exceeds maxLength characters.
pub fn hasLongerLines(value: []const u8, max_length: usize) bool {
var line_len: usize = 0;
for (value) |c| {
if (c == '\n') {
line_len = 0;
} else {
line_len += 1;
if (line_len > max_length) return true;
}
}
return false;
}
/// Encode a single word using RFC 2047 encoded-word format.
/// encoding: 'B' for base64, 'Q' for quoted-printable.
pub fn encodeWord(alloc: std.mem.Allocator, value: []const u8, encoding: u8) ![]const u8 {
if (encoding == 'Q' or encoding == 'q') {
// Quoted-printable encoded-word: =?UTF-8?Q?...?=
var buf = bun.MutableString.initEmpty(alloc);
const w = buf.writer();
try w.writeAll("=?UTF-8?Q?");
for (value) |c| {
if (c == ' ') {
try w.writeByte('_');
} else if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9')) {
try w.writeByte(c);
} else {
const hex = "0123456789ABCDEF";
try w.writeByte('=');
try w.writeByte(hex[c >> 4]);
try w.writeByte(hex[c & 0x0f]);
}
}
try w.writeAll("?=");
return buf.toOwnedSlice();
}
// Base64 encoded-word (default)
return encodeHeaderValue(alloc, value);
}
/// Encode a header value using RFC 2047 encoded-word if it contains non-ASCII.
/// Always uses base64 encoding.
pub fn encodeHeaderValue(alloc: std.mem.Allocator, value: []const u8) ![]const u8 {
var needs_encoding = false;
for (value) |c| {
if (c > 127 or c == '\r' or c == '\n') {
needs_encoding = true;
break;
}
}
if (needs_encoding) {
// Strip \r\n to prevent header injection, then base64 encode
var clean = try alloc.alloc(u8, value.len);
var clean_len: usize = 0;
for (value) |c| {
if (c != '\r' and c != '\n') {
clean[clean_len] = c;
clean_len += 1;
}
}
const src = clean[0..clean_len];
defer alloc.free(clean);
const b64_len = bun.base64.encodeLen(src);
const result = try alloc.alloc(u8, 10 + b64_len + 2);
@memcpy(result[0..10], "=?UTF-8?B?");
const encoded_len = bun.base64.encode(result[10..], src);
@memcpy(result[10 + encoded_len ..][0..2], "?=");
return result[0 .. 10 + encoded_len + 2];
}
return try alloc.dupe(u8, value);
}
/// Fold a header line at 76 characters per RFC 2822.
/// Inserts CRLF + space at word boundaries.
pub fn foldHeader(alloc: std.mem.Allocator, header: []const u8) ![]const u8 {
if (header.len <= 76) return try alloc.dupe(u8, header);
var result = bun.MutableString.initEmpty(alloc);
const w = result.writer();
var line_len: usize = 0;
var i: usize = 0;
while (i < header.len) {
// Find next word boundary (space or end)
var word_end = i;
while (word_end < header.len and header[word_end] != ' ' and header[word_end] != '\t') : (word_end += 1) {}
const word = header[i..word_end];
if (line_len > 0 and line_len + 1 + word.len > 76 and line_len > 0) {
try w.writeAll("\r\n ");
line_len = 1;
} else if (line_len > 0 and i > 0 and (header[i - 1] == ' ' or header[i - 1] == '\t')) {
// Preserve the space
}
try w.writeAll(word);
line_len += word.len;
// Skip whitespace
if (word_end < header.len) {
try w.writeByte(header[word_end]);
line_len += 1;
i = word_end + 1;
} else {
i = word_end;
}
}
return result.toOwnedSlice();
}
/// Encode a filename for Content-Disposition per RFC 5987/2231.
/// Returns the parameter string (e.g. `filename="ascii.txt"` or `filename*=utf-8''encoded`).
pub fn encodeNameParam(writer: anytype, param_name: []const u8, filename: []const u8) !void {
// Check if filename needs encoding (non-ASCII or special chars)
var needs_encoding = false;
var needs_quoting = false;
for (filename) |c| {
if (c > 127) {
needs_encoding = true;
break;
}
if (c == '"' or c == '\\' or c == ';' or c == ' ' or c == '(' or c == ')' or c == ',') needs_quoting = true;
}
if (needs_encoding) {
try writer.writeAll(param_name);
try writer.writeAll("*=utf-8''");
for (filename) |c| {
if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or
c == '.' or c == '-' or c == '_' or c == '~')
{
try writer.writeByte(c);
} else {
const hex = "0123456789ABCDEF";
try writer.writeByte('%');
try writer.writeByte(hex[c >> 4]);
try writer.writeByte(hex[c & 0x0f]);
}
}
} else if (needs_quoting) {
try writer.writeAll(param_name);
try writer.writeAll("=\"");
for (filename) |c| {
if (c == '"' or c == '\\') try writer.writeByte('\\');
try writer.writeByte(c);
}
try writer.writeByte('"');
} else {
try writer.writeAll(param_name);
try writer.writeAll("=\"");
try writer.writeAll(filename);
try writer.writeByte('"');
}
}
pub fn encodeFilenameParam(writer: anytype, filename: []const u8) !void {
try encodeNameParam(writer, "filename", filename);
}
/// Write a complete header line with folding at 76 chars.
fn writeFoldedHeaderLine(writer: anytype, alloc: std.mem.Allocator, comptime header_name: []const u8, value: []const u8) !void {
// Security: strip \r and \n from value to prevent header injection
const clean = try sanitizeHeaderValue(alloc, value);
defer alloc.free(clean);
const full = try std.fmt.allocPrint(alloc, header_name ++ ": {s}", .{clean});
defer alloc.free(full);
const folded = try foldHeader(alloc, full);
defer alloc.free(folded);
try writer.writeAll(folded);
try writer.writeAll("\r\n");
}
/// Strip \r and \n from header values to prevent header injection attacks.
/// Always returns an owned allocation that must be freed by the caller.
fn sanitizeHeaderValue(alloc: std.mem.Allocator, value: []const u8) ![]const u8 {
var has_crlf = false;
for (value) |c| {
if (c == '\r' or c == '\n') {
has_crlf = true;
break;
}
}
if (!has_crlf) return try alloc.dupe(u8, value);
var clean = try alloc.alloc(u8, value.len);
var j: usize = 0;
for (value) |ch| {
if (ch != '\r' and ch != '\n') {
clean[j] = ch;
j += 1;
}
}
if (j < clean.len) {
const result = try alloc.dupe(u8, clean[0..j]);
alloc.free(clean);
return result;
}
return clean;
}
fn writeAddressField(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue, comptime js_key: []const u8, comptime header: []const u8) !void {
if (try msg.getTruthy(globalObject, js_key)) |v| {
if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
try writeFoldedHeaderLine(writer, alloc, header, utf8.slice());
} else if (v.isArray()) {
// Build full value first, then fold
var val_buf = bun.MutableString.initEmpty(alloc);
defer val_buf.deinit();
const vw = val_buf.writer();
var first = true;
var iter = try v.arrayIterator(globalObject);
while (try iter.next()) |item| {
if (item.isString()) {
if (!first) try vw.writeAll(", ");
const s = try item.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
try vw.writeAll(utf8.slice());
first = false;
}
}
try writeFoldedHeaderLine(writer, alloc, header, val_buf.slice());
}
}
}
fn writeStringField(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue, comptime js_key: []const u8, comptime header: []const u8) !void {
if (try msg.getTruthy(globalObject, js_key)) |v| {
if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const encoded = try encodeHeaderValue(alloc, utf8.slice());
defer alloc.free(encoded);
try writeFoldedHeaderLine(writer, alloc, header, encoded);
}
}
}
fn writeDateHeader(writer: anytype) !void {
const epoch_secs: u64 = @intCast(std.time.timestamp());
const epoch_day = epoch_secs / 86400;
const day_secs = epoch_secs % 86400;
const hours = day_secs / 3600;
const mins = (day_secs % 3600) / 60;
const secs = day_secs % 60;
const z = epoch_day + 719468;
const era = z / 146097;
const doe = z - era * 146097;
const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
const y = yoe + era * 400;
const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
const mp = (5 * doy + 2) / 153;
const d = doy - (153 * mp + 2) / 5 + 1;
const m = if (mp < 10) mp + 3 else mp - 9;
const year = if (m <= 2) y + 1 else y;
const dow = (epoch_day + 4) % 7;
const day_names = [_][]const u8{ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
const month_names = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
try writer.print("Date: {s}, {d:0>2} {s} {d} {d:0>2}:{d:0>2}:{d:0>2} +0000\r\n", .{
day_names[dow], d, month_names[m - 1], year, hours, mins, secs,
});
}
fn writeListHeader(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, list_obj: jsc.JSValue, comptime js_key: []const u8, comptime header: []const u8) !void {
if (try list_obj.getTruthy(globalObject, js_key)) |v| {
if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const u = s.toUTF8WithoutRef(alloc);
defer u.deinit();
const val = u.slice();
if (bun.strings.hasPrefixComptime(val, "http") or bun.strings.hasPrefixComptime(val, "mailto:")) {
const wrapped = try std.fmt.allocPrint(alloc, "<{s}>", .{val});
defer alloc.free(wrapped);
try writeFoldedHeaderLine(writer, alloc, header, wrapped);
} else {
try writeFoldedHeaderLine(writer, alloc, header, val);
}
}
}
}
fn writeCustomHeaders(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue) !void {
if (try msg.getTruthy(globalObject, "headers")) |headers_val| {
if (headers_val.getObject()) |headers_obj| {
var iter = try jsc.JSPropertyIterator(.{
.skip_empty_name = true,
.include_value = true,
}).init(globalObject, headers_obj);
defer iter.deinit();
while (try iter.next()) |key| {
const key_slice = key.toOwnedSlice(alloc) catch continue;
defer alloc.free(key_slice);
const val = iter.value;
if (val.isString()) {
const val_s = try val.toBunString(globalObject);
defer val_s.deref();
const val_utf8 = val_s.toUTF8WithoutRef(alloc);
defer val_utf8.deinit();
// Fold custom headers at 76 chars; sanitize CRLF
const clean_key = try sanitizeHeaderValue(alloc, key_slice);
defer alloc.free(clean_key);
const clean_val = try sanitizeHeaderValue(alloc, val_utf8.slice());
defer alloc.free(clean_val);
const full = try std.fmt.allocPrint(alloc, "{s}: {s}", .{ clean_key, clean_val });
defer alloc.free(full);
const folded = try foldHeader(alloc, full);
defer alloc.free(folded);
try writer.writeAll(folded);
try writer.writeAll("\r\n");
}
}
}
}
}
/// Extract text content from a JS value that may be a string or { content, encoding } object.
/// Returns owned slice that caller must free.
fn extractTextContent(alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, val: jsc.JSValue) ![]const u8 {
if (val.isString()) {
const s = try val.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
return try alloc.dupe(u8, utf8.slice());
}
if (val.isObject()) {
// { content: "...", encoding: "base64" | "hex" }
if (try val.getTruthy(globalObject, "content")) |content_val| {
const s = try content_val.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const raw = utf8.slice();
if (try val.getTruthy(globalObject, "encoding")) |enc_val| {
const es = try enc_val.toBunString(globalObject);
defer es.deref();
const eu = es.toUTF8WithoutRef(alloc);
defer eu.deinit();
const enc = eu.slice();
if (bun.strings.eqlComptime(enc, "base64")) {
// Decode base64 into a temp buffer, then shrink to actual size
const buf = try alloc.alloc(u8, bun.base64.decodeLenUpperBound(raw.len));
const decode_result = bun.base64.decode(buf, raw);
if (decode_result.isSuccessful()) {
if (decode_result.count == buf.len) return buf;
const decoded = try alloc.dupe(u8, buf[0..decode_result.count]);
alloc.free(buf);
return decoded;
}
alloc.free(buf);
} else if (bun.strings.eqlComptime(enc, "hex")) {
// Decode hex
if (raw.len % 2 == 0) {
const decoded = try alloc.alloc(u8, raw.len / 2);
var i: usize = 0;
while (i < raw.len) : (i += 2) {
decoded[i / 2] = std.fmt.parseInt(u8, raw[i .. i + 2], 16) catch 0;
}
return decoded;
}
}
}
return try alloc.dupe(u8, raw);
}
}
return try alloc.dupe(u8, "");
}
// ============================================================================
// Body part writers
// ============================================================================
fn writeTextPart(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue, boundary: []const u8) !void {
try writer.print("--{s}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", .{boundary});
if (try msg.getTruthy(globalObject, "text")) |v| {
const content = try extractTextContent(alloc, globalObject, v);
defer alloc.free(content);
try writeQuotedPrintable(writer, content);
try writer.writeAll("\r\n");
}
}
fn writeHtmlPart(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue, boundary: []const u8) !void {
try writer.print("--{s}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", .{boundary});
if (try msg.getTruthy(globalObject, "html")) |v| {
const content = try extractTextContent(alloc, globalObject, v);
defer alloc.free(content);
try writeQuotedPrintable(writer, content);
try writer.writeAll("\r\n");
}
}
fn writeInlineText(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue) !void {
try writer.writeAll("Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n");
if (try msg.getTruthy(globalObject, "text")) |v| {
const content = try extractTextContent(alloc, globalObject, v);
defer alloc.free(content);
try writeQuotedPrintable(writer, content);
}
}
fn writeInlineHtml(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue) !void {
try writer.writeAll("Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n");
if (try msg.getTruthy(globalObject, "html")) |v| {
const content = try extractTextContent(alloc, globalObject, v);
defer alloc.free(content);
try writeQuotedPrintable(writer, content);
}
}
fn writeIcalPart(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, msg: jsc.JSValue, boundary: []const u8) !void {
if (try msg.getTruthy(globalObject, "icalEvent")) |ical_val| {
var method_owned: ?[]u8 = null;
defer if (method_owned) |m| alloc.free(m);
var content_owned: ?[]u8 = null;
defer if (content_owned) |c| alloc.free(c);
var method: []const u8 = "PUBLISH";
var content: []const u8 = "";
if (ical_val.isString()) {
const s = try ical_val.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
content_owned = try alloc.dupe(u8, utf8.slice());
content = content_owned.?;
} else if (ical_val.isObject()) {
if (try ical_val.getTruthy(globalObject, "method")) |v| {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
method_owned = try alloc.dupe(u8, utf8.slice());
method = method_owned.?;
}
if (try ical_val.getTruthy(globalObject, "content")) |v| {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
content_owned = try alloc.dupe(u8, utf8.slice());
content = content_owned.?;
}
}
try writer.print("--{s}\r\nContent-Type: text/calendar; charset=utf-8; method={s}\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", .{ boundary, method });
try writeQuotedPrintable(writer, content);
try writer.writeAll("\r\n");
}
}
// ============================================================================
// Attachment writer
// ============================================================================
fn writeAttachmentWithOpts(writer: anytype, alloc: std.mem.Allocator, globalObject: *jsc.JSGlobalObject, att: jsc.JSValue, boundary: []const u8, disable_file_access: bool) !void {
// Read all JS string values into owned buffers to avoid use-after-free
var filename_owned: ?[]u8 = null;
defer if (filename_owned) |f| alloc.free(f);
var content_type_owned: ?[]u8 = null;
defer if (content_type_owned) |c| alloc.free(c);
var cte_owned: ?[]u8 = null;
defer if (cte_owned) |c| alloc.free(c);
var cid_owned: ?[]u8 = null;
defer if (cid_owned) |c| alloc.free(c);
var has_filename = true;
if (try att.getTruthy(globalObject, "filename")) |v| {
if (v.isBoolean() and !v.toBoolean()) {
has_filename = false;
} else if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
filename_owned = try alloc.dupe(u8, utf8.slice());
}
}
const filename: []const u8 = filename_owned orelse "attachment";
var content_type: []const u8 = "application/octet-stream";
if (try att.getTruthy(globalObject, "contentType")) |v| {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
content_type_owned = try alloc.dupe(u8, utf8.slice());
content_type = content_type_owned.?;
} else if (has_filename) {
if (std.mem.lastIndexOfScalar(u8, filename, '.')) |dot_pos| {
const ext = filename[dot_pos + 1 ..];
const detected = MimeType.byExtension(ext);
if (detected.value.len > 0) content_type = detected.value;
}
}
var custom_cte: ?[]const u8 = null;
if (try att.getTruthy(globalObject, "contentTransferEncoding")) |v| {
if (v.isString()) {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
cte_owned = try alloc.dupe(u8, utf8.slice());
custom_cte = cte_owned.?;
} else if (v.isBoolean() and !v.toBoolean()) {
custom_cte = "7bit";
}
}
if (custom_cte == null and bun.strings.hasPrefixComptime(content_type, "message/")) {
custom_cte = "8bit";
}
var cid: ?[]const u8 = null;
if (try att.getTruthy(globalObject, "cid")) |v| {
const s = try v.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
cid_owned = try alloc.dupe(u8, utf8.slice());
cid = cid_owned.?;
}
try writer.print("--{s}\r\n", .{boundary});
// Content-Type with name parameter using RFC 5987 if needed
try writer.print("Content-Type: {s}", .{content_type});
if (has_filename) {
try writer.writeAll("; ");
try encodeNameParam(writer, "name", filename);
}
try writer.writeAll("\r\n");
// Content-Disposition
if (has_filename) {
if (cid) |content_id| {
try writer.writeAll("Content-Disposition: inline; ");
try encodeFilenameParam(writer, filename);
try writer.writeAll("\r\n");
const clean_cid = try sanitizeHeaderValue(alloc, content_id);
defer alloc.free(clean_cid);
try writer.print("Content-Id: <{s}>\r\n", .{clean_cid});
} else {
try writer.writeAll("Content-Disposition: attachment; ");
try encodeFilenameParam(writer, filename);
try writer.writeAll("\r\n");
}
} else {
if (cid) |content_id| {
try writer.writeAll("Content-Disposition: inline\r\n");
const clean_cid = try sanitizeHeaderValue(alloc, content_id);
defer alloc.free(clean_cid);
try writer.print("Content-Id: <{s}>\r\n", .{clean_cid});
} else {
try writer.writeAll("Content-Disposition: attachment\r\n");
}
}
const use_cte = custom_cte orelse "base64";
try writer.print("Content-Transfer-Encoding: {s}\r\n", .{use_cte});
// Per-attachment custom headers
if (try att.getTruthy(globalObject, "headers")) |headers_val| {
if (headers_val.getObject()) |headers_obj| {
var hiter = try jsc.JSPropertyIterator(.{ .skip_empty_name = true, .include_value = true }).init(globalObject, headers_obj);
defer hiter.deinit();
while (try hiter.next()) |key| {
const ks = key.toOwnedSlice(alloc) catch continue;
defer alloc.free(ks);
const hv = hiter.value;
if (hv.isString()) {
const hvs = try hv.toBunString(globalObject);
defer hvs.deref();
const hvu = hvs.toUTF8WithoutRef(alloc);
defer hvu.deinit();
const clean_hk = try sanitizeHeaderValue(alloc, ks);
defer alloc.free(clean_hk);
const clean_hv = try sanitizeHeaderValue(alloc, hvu.slice());
defer alloc.free(clean_hv);
try writer.print("{s}: {s}\r\n", .{ clean_hk, clean_hv });
} else {
// Support numeric values
const clean_hk = try sanitizeHeaderValue(alloc, ks);
defer alloc.free(clean_hk);
try writer.print("{s}: {d}\r\n", .{ clean_hk, hv.toInt32() });
}
}
}
}
try writer.writeAll("\r\n");
const is_base64 = std.mem.eql(u8, use_cte, "base64");
if (try att.getTruthy(globalObject, "content")) |content_val| {
if (content_val.isString()) {
const s = try content_val.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
if (is_base64) {
try writeBase64Wrapped(writer, alloc, utf8.slice());
} else {
try writer.writeAll(utf8.slice());
}
} else if (content_val.asArrayBuffer(globalObject)) |array_buf| {
if (is_base64) {
try writeBase64Wrapped(writer, alloc, array_buf.slice());
} else {
try writer.writeAll(array_buf.slice());
}
}
} else if (try att.getTruthy(globalObject, "path")) |path_val| {
const s = try path_val.toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const path_str = utf8.slice();
if (bun.strings.hasPrefixComptime(path_str, "data:")) {
if (std.mem.indexOf(u8, path_str, ",")) |comma_pos| {
const header = path_str[5..comma_pos];
const data_part = path_str[comma_pos + 1 ..];
if (std.mem.indexOf(u8, header, ";base64") != null) {
var pos: usize = 0;
while (pos < data_part.len) {
const end = @min(pos + 76, data_part.len);
try writer.writeAll(data_part[pos..end]);
try writer.writeAll("\r\n");
pos = end;
}
} else if (is_base64) {
try writeBase64Wrapped(writer, alloc, data_part);
} else {
try writer.writeAll(data_part);
}
}
} else if (disable_file_access) {
// File access disabled - skip
return;
} else {
var file = bun.openFile(path_str, .{ .mode = .read_only }) catch return;
defer file.close();
const file_data = file.readToEndAlloc(alloc, 50 * 1024 * 1024) catch return;
defer alloc.free(file_data);
try writeBase64Wrapped(writer, alloc, file_data);
}
}
try writer.writeAll("\r\n");
}
// ============================================================================
// Content encoding
// ============================================================================
/// Write data as base64 with 76-character line wrapping (RFC 2045).
pub fn writeBase64Wrapped(writer: anytype, alloc: std.mem.Allocator, data: []const u8) !void {
const b64_len = bun.base64.encodeLen(data);
const encoded = try alloc.alloc(u8, b64_len);
defer alloc.free(encoded);
const actual_len = bun.base64.encode(encoded, data);
const b64 = encoded[0..actual_len];
var pos: usize = 0;
while (pos < b64.len) {
const end = @min(pos + 76, b64.len);
try writer.writeAll(b64[pos..end]);
try writer.writeAll("\r\n");
pos = end;
}
}
/// Quoted-Printable encoding (RFC 2045).
/// Handles trailing whitespace encoding, soft line breaks at 76 chars.
pub fn writeQuotedPrintable(writer: anytype, data: []const u8) !void {
const hex_chars = "0123456789ABCDEF";
var line_start: usize = 0;
while (line_start <= data.len) {
var line_end = line_start;
while (line_end < data.len and data[line_end] != '\n') : (line_end += 1) {}
var line = data[line_start..line_end];
if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
// Only the very LAST trailing whitespace char needs encoding per RFC 2045
const last_char_is_ws = line.len > 0 and (line[line.len - 1] == ' ' or line[line.len - 1] == '\t');
var out_len: usize = 0;
for (line, 0..) |c, i| {
const is_last_trailing_ws = last_char_is_ws and i == line.len - 1;
const needs_encoding = is_last_trailing_ws or c == '=' or (c < 32 and c != '\t') or c > 126;
if (needs_encoding) {
if (out_len + 3 > 75) {
try writer.writeAll("=\r\n");
out_len = 0;
}
try writer.writeByte('=');
try writer.writeByte(hex_chars[c >> 4]);
try writer.writeByte(hex_chars[c & 0x0f]);
out_len += 3;
} else {
if (out_len + 1 > 75) {
try writer.writeAll("=\r\n");
out_len = 0;
}
try writer.writeByte(c);
out_len += 1;
}
}
if (line_end < data.len) {
try writer.writeAll("\r\n");
}
line_start = line_end + 1;
if (line_end >= data.len) break;
}
}
/// Testing APIs exposed via bun:internal-for-testing
pub const TestingAPIs = struct {
pub fn jsIsPlainText(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const args = callframe.arguments();
if (args.len < 1 or !args[0].isString()) return .js_undefined;
const s = try args[0].toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(bun.default_allocator);
defer utf8.deinit();
return jsc.JSValue.jsBoolean(isPlainText(utf8.slice()));
}
pub fn jsHasLongerLines(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const args = callframe.arguments();
if (args.len < 2) return .js_undefined;
const s = try args[0].toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(bun.default_allocator);
defer utf8.deinit();
return jsc.JSValue.jsBoolean(hasLongerLines(utf8.slice(), @intCast(@max(0, args[1].toInt32()))));
}
pub fn jsEncodeWord(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const args = callframe.arguments();
if (args.len < 1 or !args[0].isString()) return .js_undefined;
const alloc = bun.default_allocator;
const s = try args[0].toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const enc: u8 = if (args.len >= 2 and args[1].isString()) blk: {
const es = try args[1].toBunString(globalObject);
defer es.deref();
const eu = es.toUTF8WithoutRef(alloc);
defer eu.deinit();
break :blk if (eu.slice().len > 0) eu.slice()[0] else 'B';
} else 'B';
const encoded = try encodeWord(alloc, utf8.slice(), enc);
defer alloc.free(encoded);
const result = bun.String.createFormat("{s}", .{encoded}) catch return .js_undefined;
return result.toJS(globalObject) catch .js_undefined;
}
pub fn jsEncodeQP(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const args = callframe.arguments();
if (args.len < 1 or !args[0].isString()) return .js_undefined;
const alloc = bun.default_allocator;
const s = try args[0].toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
var buf = bun.MutableString.initEmpty(alloc);
defer buf.deinit();
try writeQuotedPrintable(buf.writer(), utf8.slice());
const result = bun.String.createFormat("{s}", .{buf.slice()}) catch return .js_undefined;
return result.toJS(globalObject) catch .js_undefined;
}
pub fn jsFoldHeader(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const args = callframe.arguments();
if (args.len < 1 or !args[0].isString()) return .js_undefined;
const alloc = bun.default_allocator;
const s = try args[0].toBunString(globalObject);
defer s.deref();
const utf8 = s.toUTF8WithoutRef(alloc);
defer utf8.deinit();
const folded = try foldHeader(alloc, utf8.slice());
defer alloc.free(folded);
const result = bun.String.createFormat("{s}", .{folded}) catch return .js_undefined;
return result.toJS(globalObject) catch .js_undefined;
}
};
const std = @import("std");
const bun = @import("bun");
const jsc = bun.jsc;

View File

@@ -1,800 +0,0 @@
/// Pure SMTP connection: protocol state machine, socket I/O, authentication.
/// No JSC dependency. The owner provides callbacks for events.
const SMTPConnection = @This();
const debug = bun.Output.scoped(.smtp, .hidden);
pub const State = enum {
disconnected,
connecting,
proxy_connect, // Waiting for HTTP CONNECT response from proxy
greeting,
ehlo,
starttls,
auth_login_user,
auth_login_pass,
auth_plain,
auth_cram_md5,
auth_xoauth2,
ready,
mail_from,
rcpt_to,
data_cmd,
data_body,
rset,
quit,
closed,
failed,
};
pub const TLSMode = enum { none, starttls, direct };
/// Standard SMTP error codes (compatible with nodemailer).
pub const ErrorCode = enum {
ECONNECTION,
ETIMEDOUT,
ESOCKET,
EPROTOCOL,
EENVELOPE,
EMESSAGE,
EAUTH,
ETLS,
ESTREAM,
EUNKNOWN,
};
/// Callback interface for the connection owner.
pub const Callbacks = struct {
/// Called when a send completes successfully. `response` is the final server response.
on_send_complete: *const fn (ctx: *anyopaque, response: []const u8) void,
/// Called when the connection enters ready state after EHLO+AUTH (for verify).
on_ready: *const fn (ctx: *anyopaque) void,
/// Called on any protocol or connection error. Includes error code for programmatic handling.
on_error: *const fn (ctx: *anyopaque, message: []const u8, code: ErrorCode) void,
/// Called when STARTTLS 220 is received; owner must call wrapTLS on the socket.
on_starttls: *const fn (ctx: *anyopaque) void,
/// Owner context pointer (the JSSMTPClient).
ctx: *anyopaque,
};
// ---- Fields ----
state: State = .disconnected,
host: []const u8 = "",
port: u16 = 587,
tls_mode: TLSMode = .none,
local_hostname: []const u8 = "[127.0.0.1]",
auth_user: []const u8 = "",
auth_pass: []const u8 = "",
auth_method: []const u8 = "", // Force specific method: "PLAIN", "LOGIN", "CRAM-MD5", "XOAUTH2"
auth_xoauth2_token: []const u8 = "", // Pre-generated XOAUTH2 token
lmtp: bool = false, // Use LMTP protocol (LHLO instead of EHLO)
require_tls: bool = false,
ignore_tls: bool = false,
secure: bool = false,
// Proxy settings
proxy_host: []const u8 = "",
proxy_port: u16 = 0,
proxy_auth: []const u8 = "", // "user:pass" for Proxy-Authorization Basic
// REQUIRETLS (RFC 8689)
require_tls_extension: bool = false, // User wants REQUIRETLS in MAIL FROM
// Server capabilities (parsed from EHLO)
supports_starttls: bool = false,
supports_requiretls: bool = false, // Server advertises REQUIRETLS
supported_auth_plain: bool = false,
supported_auth_login: bool = false,
supported_auth_cram_md5: bool = false,
supported_auth_xoauth2: bool = false,
server_max_size: u64 = 0,
// Socket
socket: Socket = .{ .SocketTCP = .detached },
socket_ctx: ?*uws.SocketContext = null,
// Buffers
ehlo_lines: bun.MutableString = .{ .allocator = bun.default_allocator, .list = .{} },
read_buffer: bun.MutableString = .{ .allocator = bun.default_allocator, .list = .{} },
write_buffer: bun.OffsetByteList = .{},
has_backpressure: bool = false,
// Send state
envelope_from: []const u8 = "",
envelope_to: []const []const u8 = &.{},
message_data: []const u8 = "",
current_rcpt_index: usize = 0,
accepted_count: usize = 0,
rejected_count: usize = 0,
// Dynamic arrays tracking accepted/rejected recipient indices
accepted_indices: std.ArrayListUnmanaged(u16) = .{},
rejected_indices: std.ArrayListUnmanaged(u16) = .{},
pending_send: bool = false,
callbacks: Callbacks,
// ========== Public API ==========
/// Start a new send operation. Caller must have already set envelope_from, envelope_to, message_data.
pub fn startSend(this: *SMTPConnection) void {
this.current_rcpt_index = 0;
this.accepted_count = 0;
this.rejected_count = 0;
this.accepted_indices.clearRetainingCapacity();
this.rejected_indices.clearRetainingCapacity();
if (this.state == .ready) {
this.doStartSending();
} else {
this.pending_send = true;
}
}
/// Reset connection capabilities for a fresh connection.
pub fn resetCapabilities(this: *SMTPConnection) void {
this.supports_starttls = false;
this.supports_requiretls = false;
this.supported_auth_plain = false;
this.supported_auth_login = false;
this.supported_auth_cram_md5 = false;
this.supported_auth_xoauth2 = false;
this.server_max_size = 0;
this.state = .disconnected;
}
/// Close the connection gracefully.
pub fn closeSocket(this: *SMTPConnection) void {
if (this.state == .closed) return;
if ((this.state == .ready or this.state == .rset) and !this.socket.isClosed()) {
this.writeAll("QUIT\r\n");
}
this.state = .closed;
if (!this.socket.isClosed()) this.socket.close();
this.socket = .{ .SocketTCP = .detached };
}
pub fn isVerifyMode(this: *const SMTPConnection) bool {
return this.envelope_from.len == 0 and this.message_data.len == 0;
}
// ========== Socket Handler (compile-time generic for TCP/TLS) ==========
pub fn SocketHandler(comptime ssl: bool, comptime Owner: type) type {
return struct {
const SocketType = uws.NewSocketHandler(ssl);
fn _socket(s: SocketType) Socket {
if (comptime ssl) return Socket{ .SocketTLS = s };
return Socket{ .SocketTCP = s };
}
pub fn onOpen(owner: *Owner, s: SocketType) void {
const conn = owner.connection();
debug("onOpen: {s}:{d}", .{ conn.host, conn.port });
conn.socket = _socket(s);
if (conn.proxy_host.len > 0 and conn.state == .connecting) {
// Connected to proxy - send HTTP CONNECT
conn.sendProxyConnect();
} else {
conn.state = .greeting;
}
owner.onSocketOpen();
}
fn onHandshake_(owner: *Owner, _: anytype, success: i32, ssl_error: uws.us_bun_verify_error_t) void {
_ = ssl_error;
const conn = owner.connection();
if (success != 1) {
conn.onErrorWithCode("TLS handshake failed", .ETLS);
return;
}
conn.secure = true;
if (conn.state == .starttls) {
conn.state = .ehlo;
conn.writeCmd("EHLO {s}\r\n", .{conn.local_hostname});
}
}
pub const onHandshake = if (ssl) onHandshake_ else null;
pub fn onClose(owner: *Owner, _: SocketType, _: i32, _: ?*anyopaque) void {
const conn = owner.connection();
conn.socket = .{ .SocketTCP = .detached };
if (conn.state != .closed and conn.state != .failed) conn.onErrorWithCode("Connection closed unexpectedly", .ECONNECTION);
owner.onSocketClose();
}
pub fn onEnd(owner: *Owner, _: SocketType) void {
const conn = owner.connection();
if (conn.state != .closed and conn.state != .failed and conn.state != .quit) {
conn.socket = .{ .SocketTCP = .detached };
conn.onErrorWithCode("Connection closed unexpectedly", .ECONNECTION);
owner.onSocketClose();
}
}
pub fn onConnectError(owner: *Owner, _: SocketType, _: i32) void {
const conn = owner.connection();
conn.socket = .{ .SocketTCP = .detached };
conn.onErrorWithCode("Failed to connect to SMTP server", .ECONNECTION);
owner.onSocketClose();
}
pub fn onTimeout(owner: *Owner, _: SocketType) void {
owner.connection().onErrorWithCode("Socket timeout", .ETIMEDOUT);
}
pub fn onWritable(owner: *Owner, _: SocketType) void {
owner.connection().flushWriteBuffer();
}
pub fn onLongTimeout(_: *Owner, _: SocketType) void {}
pub fn onData(owner: *Owner, _: SocketType, data: []const u8) void {
owner.onSocketData(data);
}
};
}
// ========== Data Processing ==========
pub fn processIncomingData(this: *SMTPConnection, data: []const u8) void {
this.read_buffer.appendSlice(data) catch return;
// Handle HTTP CONNECT proxy response (not SMTP protocol)
if (this.state == .proxy_connect) {
this.handleProxyResponse();
if (this.state == .proxy_connect) return; // Still waiting for full response
if (this.state == .failed) return;
// Fall through to process any remaining SMTP data in the buffer
}
while (true) {
const buf = this.read_buffer.slice();
const nl = std.mem.indexOf(u8, buf, "\r\n") orelse return;
const line = buf[0..nl];
if (line.len >= 4 and line[3] == '-') {
this.ehlo_lines.appendSlice(line) catch {};
this.ehlo_lines.appendSlice("\n") catch {};
this.consumeReadBuffer(nl + 2);
continue;
}
this.ehlo_lines.appendSlice(line) catch {};
const full = this.ehlo_lines.slice();
this.handleResponse(full);
this.ehlo_lines.list.clearRetainingCapacity();
this.consumeReadBuffer(nl + 2);
}
}
fn consumeReadBuffer(this: *SMTPConnection, bytes: usize) void {
const buf = this.read_buffer.slice();
if (bytes >= buf.len) {
this.read_buffer.list.clearRetainingCapacity();
return;
}
const rem = buf[bytes..];
std.mem.copyForwards(u8, this.read_buffer.list.items[0..rem.len], rem);
this.read_buffer.list.items.len = rem.len;
}
// ========== Protocol Response Dispatch ==========
fn handleResponse(this: *SMTPConnection, resp: []const u8) void {
if (resp.len < 3) {
this.onError("Invalid response");
return;
}
const code = std.fmt.parseInt(u16, resp[0..3], 10) catch {
this.onError("Invalid response code");
return;
};
debug("SMTP {d} state={s}", .{ code, @tagName(this.state) });
switch (this.state) {
.greeting => this.handleGreeting(code),
.ehlo => this.handleEhlo(code, resp),
.starttls => this.handleStartTLS(code),
.auth_plain => this.handleAuthResult(code),
.auth_login_user => this.handleAuthLoginUser(code),
.auth_login_pass => this.handleAuthLoginPass(code),
.auth_cram_md5 => this.handleAuthCramMD5(code, resp),
.auth_xoauth2 => this.handleAuthResult(code),
.mail_from => this.handleMailFrom(code),
.rcpt_to => this.handleRcptTo(code),
.data_cmd => {
if (code != 354 and code != 250) this.onError("DATA command rejected") else {
this.state = .data_body;
this.sendMessageData();
}
},
.data_body => this.handleDataBody(code, resp),
.rset => {
this.state = .ready;
if (this.pending_send) {
this.pending_send = false;
this.doStartSending();
}
},
.quit => {
this.state = .closed;
if (!this.socket.isClosed()) this.socket.close();
this.socket = .{ .SocketTCP = .detached };
},
else => {},
}
}
// ========== State Handlers ==========
fn handleGreeting(this: *SMTPConnection, code: u16) void {
if (code != 220) {
this.onError("Invalid greeting from server");
return;
}
this.state = .ehlo;
if (this.lmtp) {
this.writeCmd("LHLO {s}\r\n", .{this.local_hostname});
return;
}
this.writeCmd("EHLO {s}\r\n", .{this.local_hostname});
}
fn handleEhlo(this: *SMTPConnection, code: u16, response: []const u8) void {
if (code == 421) {
this.onError("Server terminating connection");
return;
}
if (code != 250) {
if (this.require_tls) {
this.onError("EHLO failed but STARTTLS is required");
return;
}
this.writeCmd("HELO {s}\r\n", .{this.local_hostname});
return;
}
this.parseEhloExtensions(response);
// STARTTLS negotiation
if (!this.secure and !this.ignore_tls and this.tls_mode != .direct) {
if (this.supports_starttls) {
this.state = .starttls;
this.writeAll("STARTTLS\r\n");
return;
} else if (this.require_tls) {
this.onError("Server does not support STARTTLS but requireTLS is set");
return;
}
}
this.proceedAfterEhlo();
}
fn handleStartTLS(this: *SMTPConnection, code: u16) void {
if (code != 220) {
this.onError("STARTTLS rejected by server");
return;
}
// Ask the owner to perform the TLS upgrade. The owner must call wrapTLS
// on the socket. Once the TLS handshake completes, onHandshake will
// transition to ehlo state and re-send EHLO.
this.callbacks.on_starttls(this.callbacks.ctx);
}
fn handleAuthResult(this: *SMTPConnection, code: u16) void {
if (code == 235) {
if (this.isVerifyMode()) {
this.callbacks.on_ready(this.callbacks.ctx);
this.state = .quit;
this.writeAll("QUIT\r\n");
} else {
this.doStartSending();
}
return;
}
this.onErrorWithCode("Authentication failed", .EAUTH);
}
fn handleAuthLoginUser(this: *SMTPConnection, code: u16) void {
if (code != 334) {
this.onError("AUTH LOGIN failed");
return;
}
// Server sent "334 VXNlcm5hbWU6" (Username:), send base64(username)
var buf: [1024]u8 = undefined;
const len = bun.base64.encode(&buf, this.auth_user);
this.state = .auth_login_pass;
this.writeAll(buf[0..len]);
this.writeAll("\r\n");
}
fn handleAuthLoginPass(this: *SMTPConnection, code: u16) void {
if (code == 334) {
// Server sent "334 UGFzc3dvcmQ6" (Password:), send base64(password)
var buf: [1024]u8 = undefined;
const len = bun.base64.encode(&buf, this.auth_pass);
this.writeAll(buf[0..len]);
this.writeAll("\r\n");
return;
}
if (code == 235) {
if (this.isVerifyMode()) {
this.callbacks.on_ready(this.callbacks.ctx);
this.state = .quit;
this.writeAll("QUIT\r\n");
} else {
this.doStartSending();
}
return;
}
this.onErrorWithCode("Authentication failed", .EAUTH);
}
fn handleMailFrom(this: *SMTPConnection, code: u16) void {
if (code != 250) {
this.onErrorWithCode("MAIL FROM rejected by server", .EENVELOPE);
return;
}
this.current_rcpt_index = 0;
this.sendNextRcptTo();
}
fn handleRcptTo(this: *SMTPConnection, code: u16) void {
if (code == 250 or code == 251) {
this.accepted_indices.append(bun.default_allocator, @intCast(this.current_rcpt_index)) catch {};
this.accepted_count += 1;
} else {
this.rejected_indices.append(bun.default_allocator, @intCast(this.current_rcpt_index)) catch {};
this.rejected_count += 1;
}
this.current_rcpt_index += 1;
if (this.current_rcpt_index < this.envelope_to.len) {
this.sendNextRcptTo();
} else {
if (this.accepted_count == 0) {
this.onErrorWithCode("All recipients were rejected", .EENVELOPE);
return;
}
this.state = .data_cmd;
this.writeAll("DATA\r\n");
}
}
fn handleDataBody(this: *SMTPConnection, code: u16, line: []const u8) void {
if (code != 250) {
this.onErrorWithCode("Message rejected by server", .EMESSAGE);
return;
}
// Set state and send RSET BEFORE the callback, so that pool queue
// processing in the callback sees state == .rset (not .data_body).
this.state = .rset;
this.writeAll("RSET\r\n");
this.callbacks.on_send_complete(this.callbacks.ctx, line);
}
// ========== Proxy Support ==========
/// Send the HTTP CONNECT request to the proxy server.
pub fn sendProxyConnect(this: *SMTPConnection) void {
this.state = .proxy_connect;
const alloc = bun.default_allocator;
// HTTP CONNECT request - use writeAll directly (not writeCmd which sanitizes CRLF)
const header = std.fmt.allocPrint(alloc, "CONNECT {s}:{d} HTTP/1.1\r\nHost: {s}:{d}\r\n", .{ this.host, this.port, this.host, this.port }) catch return;
defer alloc.free(header);
this.writeAll(header);
if (this.proxy_auth.len > 0) {
const b64_buf = alloc.alloc(u8, bun.base64.encodeLenFromSize(this.proxy_auth.len)) catch return;
defer alloc.free(b64_buf);
const b64_len = bun.base64.encode(b64_buf, this.proxy_auth);
const auth_line = std.fmt.allocPrint(alloc, "Proxy-Authorization: Basic {s}\r\n", .{b64_buf[0..b64_len]}) catch return;
defer alloc.free(auth_line);
this.writeAll(auth_line);
}
this.writeAll("\r\n");
}
/// Handle the HTTP response from a CONNECT proxy. Looks for \r\n\r\n terminator
/// and checks that the status is 2xx.
fn handleProxyResponse(this: *SMTPConnection) void {
const buf = this.read_buffer.slice();
// Look for the end of HTTP headers
const header_end = std.mem.indexOf(u8, buf, "\r\n\r\n") orelse return; // Need more data
// Extract first line to check status code: "HTTP/1.x 200 ..."
const first_line_end = std.mem.indexOf(u8, buf[0..header_end], "\r\n") orelse header_end;
const first_line = buf[0..first_line_end];
// Parse status code - find first space, then read 3 digits
var ok = false;
if (std.mem.indexOf(u8, first_line, " ")) |space_idx| {
if (space_idx + 1 < first_line.len and first_line[space_idx + 1] == '2') {
ok = true; // 2xx response
}
}
if (!ok) {
this.onErrorWithCode("Proxy CONNECT failed", .ECONNECTION);
return;
}
// Consume the HTTP response headers from the buffer, leave any SMTP data
this.consumeReadBuffer(header_end + 4);
// Proxy tunnel established. For direct TLS, the owner needs to upgrade.
// For non-TLS, transition to greeting state (server greeting will arrive).
if (this.tls_mode == .direct) {
// Owner must upgrade to TLS, then we'll get the greeting via onHandshake/onOpen
this.callbacks.on_starttls(this.callbacks.ctx);
}
this.state = .greeting;
}
// ========== Protocol Helpers ==========
fn proceedAfterEhlo(this: *SMTPConnection) void {
if (this.auth_user.len > 0) {
this.startAuth();
} else if (this.isVerifyMode()) {
this.callbacks.on_ready(this.callbacks.ctx);
this.state = .quit;
this.writeAll("QUIT\r\n");
} else {
this.doStartSending();
}
}
fn parseEhloExtensions(this: *SMTPConnection, r: []const u8) void {
if (std.ascii.indexOfIgnoreCase(r, "STARTTLS") != null) this.supports_starttls = true;
if (std.ascii.indexOfIgnoreCase(r, "REQUIRETLS") != null) this.supports_requiretls = true;
if (std.ascii.indexOfIgnoreCase(r, "AUTH") != null) {
if (std.ascii.indexOfIgnoreCase(r, "PLAIN") != null) this.supported_auth_plain = true;
if (std.ascii.indexOfIgnoreCase(r, "LOGIN") != null) this.supported_auth_login = true;
if (std.ascii.indexOfIgnoreCase(r, "CRAM-MD5") != null) this.supported_auth_cram_md5 = true;
if (std.ascii.indexOfIgnoreCase(r, "XOAUTH2") != null) this.supported_auth_xoauth2 = true;
}
if (std.ascii.indexOfIgnoreCase(r, "SIZE")) |pos| {
var i = pos + 4;
while (i < r.len and (r[i] == ' ' or r[i] == '\t')) : (i += 1) {}
var end = i;
while (end < r.len and r[end] >= '0' and r[end] <= '9') : (end += 1) {}
if (end > i) this.server_max_size = std.fmt.parseInt(u64, r[i..end], 10) catch 0;
}
}
fn startAuth(this: *SMTPConnection) void {
// XOAUTH2: if token provided or method forced
if (this.auth_xoauth2_token.len > 0 or (this.auth_method.len >= 7 and std.ascii.eqlIgnoreCase(this.auth_method, "XOAUTH2"))) {
this.state = .auth_xoauth2;
if (this.auth_xoauth2_token.len > 0) {
// Pre-built token
this.writeCmd("AUTH XOAUTH2 {s}\r\n", .{this.auth_xoauth2_token});
} else if (this.auth_user.len > 0 and this.auth_pass.len > 0) {
// Build XOAUTH2 token: base64("user=" + user + "\x01auth=Bearer " + token + "\x01\x01")
var token_buf: [2048]u8 = undefined;
const token_data = std.fmt.bufPrint(&token_buf, "user={s}\x01auth=Bearer {s}\x01\x01", .{ this.auth_user, this.auth_pass }) catch {
this.onErrorWithCode("XOAUTH2 token too long", .EAUTH);
return;
};
var b64_buf: [4096]u8 = undefined;
const b64_len = bun.base64.encode(&b64_buf, token_data);
this.writeCmd("AUTH XOAUTH2 {s}\r\n", .{b64_buf[0..b64_len]});
}
return;
}
// Determine method: explicit > best available
const use_cram = (this.auth_method.len >= 8 and std.ascii.eqlIgnoreCase(this.auth_method, "CRAM-MD5")) or
(this.auth_method.len == 0 and this.supported_auth_cram_md5 and !this.supported_auth_plain);
const use_login = (this.auth_method.len >= 5 and std.ascii.eqlIgnoreCase(this.auth_method, "LOGIN")) or
(this.auth_method.len == 0 and !this.supported_auth_plain and this.supported_auth_login and !use_cram);
if (use_cram) {
this.state = .auth_cram_md5;
this.writeAll("AUTH CRAM-MD5\r\n");
} else if (use_login) {
this.state = .auth_login_user;
this.writeAll("AUTH LOGIN\r\n");
} else {
// AUTH PLAIN (default)
this.state = .auth_plain;
var plain: [1024]u8 = undefined;
var p: usize = 0;
plain[p] = 0;
p += 1;
@memcpy(plain[p .. p + this.auth_user.len], this.auth_user);
p += this.auth_user.len;
plain[p] = 0;
p += 1;
@memcpy(plain[p .. p + this.auth_pass.len], this.auth_pass);
p += this.auth_pass.len;
var b64: [2048]u8 = undefined;
const len = bun.base64.encode(&b64, plain[0..p]);
this.writeCmd("AUTH PLAIN {s}\r\n", .{b64[0..len]});
}
}
fn handleAuthCramMD5(this: *SMTPConnection, code: u16, resp: []const u8) void {
if (code != 334) {
this.onError("AUTH CRAM-MD5 failed");
return;
}
// Server sent "334 <base64 challenge>"
// Extract challenge after "334 "
const challenge_b64 = if (resp.len > 4) resp[4..] else "";
// Decode challenge from base64
var challenge_buf: [512]u8 = undefined;
const decode_result = bun.base64.decode(&challenge_buf, challenge_b64);
if (!decode_result.isSuccessful()) {
this.onError("Invalid CRAM-MD5 challenge");
return;
}
const challenge = challenge_buf[0..decode_result.count];
// HMAC-MD5(password, challenge)
const c = bun.BoringSSL.c;
const hmac_ctx = c.HMAC_CTX_new() orelse {
this.onError("Failed to create HMAC context");
return;
};
defer c.HMAC_CTX_free(hmac_ctx);
if (c.HMAC_Init_ex(hmac_ctx, this.auth_pass.ptr, this.auth_pass.len, c.EVP_md5(), null) != 1) {
this.onError("HMAC init failed");
return;
}
_ = c.HMAC_Update(hmac_ctx, challenge.ptr, challenge.len);
var hmac_result: [16]u8 = undefined; // MD5 = 16 bytes
var hmac_len: c_uint = 16;
_ = c.HMAC_Final(hmac_ctx, &hmac_result, &hmac_len);
// Build response: "username hex-digest"
var response_buf: [512]u8 = undefined;
const hex = std.fmt.bytesToHex(hmac_result, .lower);
const response = std.fmt.bufPrint(&response_buf, "{s} {s}", .{ this.auth_user, hex }) catch {
this.onError("CRAM-MD5 response too long");
return;
};
// Base64 encode and send
var b64_buf: [1024]u8 = undefined;
const b64_len = bun.base64.encode(&b64_buf, response);
this.state = .auth_plain; // Reuse auth_plain state for the final 235 response
this.writeAll(b64_buf[0..b64_len]);
this.writeAll("\r\n");
}
fn doStartSending(this: *SMTPConnection) void {
if (this.envelope_from.len == 0) return;
if (this.server_max_size > 0 and this.message_data.len > this.server_max_size) {
this.onErrorWithCode("Message size exceeds server limit", .EMESSAGE);
return;
}
// REQUIRETLS (RFC 8689): error if requested but not supported
if (this.require_tls_extension and !this.supports_requiretls) {
this.onErrorWithCode("Server does not support REQUIRETLS extension", .EENVELOPE);
return;
}
this.state = .mail_from;
const requiretls_param: []const u8 = if (this.require_tls_extension and this.supports_requiretls) " REQUIRETLS" else "";
if (this.server_max_size > 0) {
this.writeCmd("MAIL FROM:<{s}> SIZE={d}{s}\r\n", .{ this.envelope_from, this.message_data.len, requiretls_param });
} else if (requiretls_param.len > 0) {
this.writeCmd("MAIL FROM:<{s}>{s}\r\n", .{ this.envelope_from, requiretls_param });
} else {
this.writeCmd("MAIL FROM:<{s}>\r\n", .{this.envelope_from});
}
}
fn sendNextRcptTo(this: *SMTPConnection) void {
if (this.current_rcpt_index >= this.envelope_to.len) return;
this.state = .rcpt_to;
this.writeCmd("RCPT TO:<{s}>\r\n", .{this.envelope_to[this.current_rcpt_index]});
}
fn sendMessageData(this: *SMTPConnection) void {
const data = this.message_data;
var i: usize = 0;
var ls: usize = 0;
// RFC 5321 4.5.2: dot-stuff lines starting with "." after CRLF
if (data.len > 0 and data[0] == '.') this.writeAll(".");
while (i < data.len) : (i += 1) {
if (data[i] == '\r' and i + 1 < data.len and data[i + 1] == '\n' and i + 2 < data.len and data[i + 2] == '.') {
// Write through the \r\n, then add extra dot
this.writeAll(data[ls .. i + 2]);
this.writeAll(".");
ls = i + 2;
}
}
if (ls < data.len) this.writeAll(data[ls..]);
// Ensure message ends with CRLF before the terminating dot
if (data.len < 2 or data[data.len - 2] != '\r' or data[data.len - 1] != '\n') this.writeAll("\r\n");
this.writeAll(".\r\n");
}
/// Buffered write: appends to write_buffer, then flushes what the socket can accept.
pub fn writeAll(this: *SMTPConnection, data: []const u8) void {
if (this.has_backpressure) {
// Socket is full, just buffer
this.write_buffer.write(bun.default_allocator, data) catch return;
return;
}
// Try direct write first
if (this.write_buffer.len() == 0) {
const wrote = this.socket.write(data);
if (wrote < 0) return; // socket error
const written: usize = @intCast(wrote);
if (written < data.len) {
// Partial write - buffer remainder
this.has_backpressure = true;
this.write_buffer.write(bun.default_allocator, data[written..]) catch return;
}
return;
}
// Have pending data, append to buffer
this.write_buffer.write(bun.default_allocator, data) catch return;
this.flushWriteBuffer();
}
/// Flush pending write buffer to socket. Called from onWritable.
pub fn flushWriteBuffer(this: *SMTPConnection) void {
const chunk = this.write_buffer.remaining();
if (chunk.len == 0) {
this.has_backpressure = false;
return;
}
const wrote = this.socket.write(chunk);
if (wrote > 0) {
this.write_buffer.consume(@intCast(wrote));
}
this.has_backpressure = this.write_buffer.len() > 0;
}
pub fn writeCmd(this: *SMTPConnection, comptime fmt: []const u8, args: anytype) void {
const cmd = std.fmt.allocPrint(bun.default_allocator, fmt, args) catch return;
defer bun.default_allocator.free(cmd);
// Security: sanitize embedded CRLF in user-controlled values.
// The only legitimate \r\n should be the trailing command terminator.
// Strip any \r or \n that appear before the final \r\n.
if (cmd.len >= 2 and cmd[cmd.len - 2] == '\r' and cmd[cmd.len - 1] == '\n') {
const sanitized = bun.default_allocator.alloc(u8, cmd.len) catch {
this.writeAll(cmd);
return;
};
defer bun.default_allocator.free(sanitized);
var j: usize = 0;
for (cmd[0 .. cmd.len - 2]) |c| {
if (c != '\r' and c != '\n') {
sanitized[j] = c;
j += 1;
}
}
sanitized[j] = '\r';
sanitized[j + 1] = '\n';
this.writeAll(sanitized[0 .. j + 2]);
} else {
this.writeAll(cmd);
}
}
fn onError(this: *SMTPConnection, message: []const u8) void {
this.onErrorWithCode(message, .EPROTOCOL);
}
pub fn onErrorWithCode(this: *SMTPConnection, message: []const u8, code: ErrorCode) void {
if (this.state == .closed or this.state == .failed) return;
this.state = .failed;
this.callbacks.on_error(this.callbacks.ctx, message, code);
}
// ========== Cleanup ==========
pub fn deinit(this: *SMTPConnection) void {
this.ehlo_lines.deinit();
this.read_buffer.deinit();
this.write_buffer.deinit(bun.default_allocator);
}
const bun = @import("bun");
const std = @import("std");
const uws = bun.uws;
const Socket = uws.AnySocket;

View File

@@ -1,107 +0,0 @@
/// Well-known SMTP service configurations (ported from nodemailer)
pub const ServiceConfig = struct {
host: []const u8,
port: u16,
secure: bool,
};
/// Lookup a well-known SMTP service by name or email domain.
/// Returns null if not found.
pub fn lookup(key: []const u8) ?ServiceConfig {
// Normalize: lowercase, strip non-alnum except . and -
var buf: [128]u8 = undefined;
var len: usize = 0;
for (key) |c| {
if (len >= buf.len) break;
if (c >= 'A' and c <= 'Z') {
buf[len] = c + 32;
len += 1;
} else if ((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or c == '.' or c == '-') {
buf[len] = c;
len += 1;
}
}
const normalized = buf[0..len];
return services.get(normalized);
}
const S = ServiceConfig;
const services = bun.ComptimeStringMap(ServiceConfig, .{
.{ "gmail", S{ .host = "smtp.gmail.com", .port = 465, .secure = true } },
.{ "googlemail", S{ .host = "smtp.gmail.com", .port = 465, .secure = true } },
.{ "gmail.com", S{ .host = "smtp.gmail.com", .port = 465, .secure = true } },
.{ "googlemail.com", S{ .host = "smtp.gmail.com", .port = 465, .secure = true } },
.{ "outlook365", S{ .host = "smtp.office365.com", .port = 587, .secure = false } },
.{ "outlook", S{ .host = "smtp-mail.outlook.com", .port = 587, .secure = false } },
.{ "hotmail", S{ .host = "smtp-mail.outlook.com", .port = 587, .secure = false } },
.{ "live", S{ .host = "smtp-mail.outlook.com", .port = 587, .secure = false } },
.{ "outlook.com", S{ .host = "smtp-mail.outlook.com", .port = 587, .secure = false } },
.{ "hotmail.com", S{ .host = "smtp-mail.outlook.com", .port = 587, .secure = false } },
.{ "live.com", S{ .host = "smtp-mail.outlook.com", .port = 587, .secure = false } },
.{ "yahoo", S{ .host = "smtp.mail.yahoo.com", .port = 465, .secure = true } },
.{ "yahoo.com", S{ .host = "smtp.mail.yahoo.com", .port = 465, .secure = true } },
.{ "icloud", S{ .host = "smtp.mail.me.com", .port = 587, .secure = false } },
.{ "me.com", S{ .host = "smtp.mail.me.com", .port = 587, .secure = false } },
.{ "icloud.com", S{ .host = "smtp.mail.me.com", .port = 587, .secure = false } },
.{ "aol", S{ .host = "smtp.aol.com", .port = 587, .secure = false } },
.{ "aol.com", S{ .host = "smtp.aol.com", .port = 587, .secure = false } },
.{ "fastmail", S{ .host = "smtp.fastmail.com", .port = 465, .secure = true } },
.{ "fastmail.fm", S{ .host = "smtp.fastmail.com", .port = 465, .secure = true } },
.{ "zoho", S{ .host = "smtp.zoho.com", .port = 465, .secure = true } },
.{ "zohomail", S{ .host = "smtp.zoho.com", .port = 465, .secure = true } },
.{ "zoho.com", S{ .host = "smtp.zoho.com", .port = 465, .secure = true } },
.{ "protonmail", S{ .host = "127.0.0.1", .port = 1025, .secure = false } },
.{ "proton", S{ .host = "127.0.0.1", .port = 1025, .secure = false } },
.{ "mailgun", S{ .host = "smtp.mailgun.org", .port = 465, .secure = true } },
.{ "sendgrid", S{ .host = "smtp.sendgrid.net", .port = 587, .secure = false } },
.{ "ses", S{ .host = "email-smtp.us-east-1.amazonaws.com", .port = 465, .secure = true } },
.{ "ses-us-east-1", S{ .host = "email-smtp.us-east-1.amazonaws.com", .port = 465, .secure = true } },
.{ "ses-us-west-2", S{ .host = "email-smtp.us-west-2.amazonaws.com", .port = 465, .secure = true } },
.{ "ses-eu-west-1", S{ .host = "email-smtp.eu-west-1.amazonaws.com", .port = 465, .secure = true } },
.{ "postmark", S{ .host = "smtp.postmarkapp.com", .port = 587, .secure = false } },
.{ "mandrill", S{ .host = "smtp.mandrillapp.com", .port = 587, .secure = false } },
.{ "sparkpost", S{ .host = "smtp.sparkpostmail.com", .port = 587, .secure = false } },
.{ "ethereal", S{ .host = "smtp.ethereal.email", .port = 587, .secure = false } },
.{ "ethereal.email", S{ .host = "smtp.ethereal.email", .port = 587, .secure = false } },
.{ "qq", S{ .host = "smtp.qq.com", .port = 465, .secure = true } },
.{ "qq.com", S{ .host = "smtp.qq.com", .port = 465, .secure = true } },
.{ "126", S{ .host = "smtp.126.com", .port = 465, .secure = true } },
.{ "163", S{ .host = "smtp.163.com", .port = 465, .secure = true } },
.{ "gmx", S{ .host = "mail.gmx.com", .port = 587, .secure = false } },
.{ "gmx.com", S{ .host = "mail.gmx.com", .port = 587, .secure = false } },
.{ "gmx.de", S{ .host = "mail.gmx.com", .port = 587, .secure = false } },
.{ "1und1", S{ .host = "smtp.1und1.de", .port = 465, .secure = true } },
.{ "yandex", S{ .host = "smtp.yandex.com", .port = 465, .secure = true } },
.{ "yandex.com", S{ .host = "smtp.yandex.com", .port = 465, .secure = true } },
.{ "yandex.ru", S{ .host = "smtp.yandex.com", .port = 465, .secure = true } },
.{ "mail.ru", S{ .host = "smtp.mail.ru", .port = 465, .secure = true } },
.{ "mailru", S{ .host = "smtp.mail.ru", .port = 465, .secure = true } },
.{ "gandi", S{ .host = "mail.gandi.net", .port = 587, .secure = false } },
.{ "gandimail", S{ .host = "mail.gandi.net", .port = 587, .secure = false } },
.{ "ovh", S{ .host = "ssl0.ovh.net", .port = 465, .secure = true } },
.{ "mailjet", S{ .host = "in-v3.mailjet.com", .port = 587, .secure = false } },
.{ "forwardemail", S{ .host = "smtp.forwardemail.net", .port = 465, .secure = true } },
.{ "elasticemail", S{ .host = "smtp.elasticemail.com", .port = 465, .secure = true } },
.{ "feishu", S{ .host = "smtp.feishu.cn", .port = 465, .secure = true } },
});
const bun = @import("bun");

View File

@@ -422,19 +422,6 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra
break :brk b.allocatedSlice();
};
// Reject null bytes in connection parameters to prevent protocol injection
// (null bytes act as field terminators in the MySQL wire protocol).
inline for (.{ .{ username, "username" }, .{ password, "password" }, .{ database, "database" }, .{ path, "path" } }) |entry| {
if (entry[0].len > 0 and std.mem.indexOfScalar(u8, entry[0], 0) != null) {
bun.default_allocator.free(options_buf);
tls_config.deinit();
if (tls_ctx) |tls| {
tls.deinit(true);
}
return globalObject.throwInvalidArguments(entry[1] ++ " must not contain null bytes", .{});
}
}
const on_connect = arguments[9];
const on_close = arguments[10];
const idle_timeout = arguments[11].toInt32();

View File

@@ -680,20 +680,6 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
break :brk b.allocatedSlice();
};
// Reject null bytes in connection parameters to prevent Postgres startup
// message parameter injection (null bytes act as field terminators in the
// wire protocol's key\0value\0 format).
inline for (.{ .{ username, "username" }, .{ password, "password" }, .{ database, "database" }, .{ path, "path" } }) |entry| {
if (entry[0].len > 0 and std.mem.indexOfScalar(u8, entry[0], 0) != null) {
bun.default_allocator.free(options_buf);
tls_config.deinit();
if (tls_ctx) |tls| {
tls.deinit(true);
}
return globalObject.throwInvalidArguments(entry[1] ++ " must not contain null bytes", .{});
}
}
const on_connect = arguments[9];
const on_close = arguments[10];
const idle_timeout = arguments[11].toInt32();
@@ -1640,10 +1626,7 @@ pub fn on(this: *PostgresSQLConnection, comptime MessageType: @Type(.enum_litera
// This will usually start with "v="
const comparison_signature = final.data.slice();
if (comparison_signature.len < 2 or
server_signature.len != comparison_signature.len - 2 or
BoringSSL.c.CRYPTO_memcmp(server_signature.ptr, comparison_signature[2..].ptr, server_signature.len) != 0)
{
if (comparison_signature.len < 2 or !bun.strings.eqlLong(server_signature, comparison_signature[2..], true)) {
debug("SASLFinal - SASL Server signature mismatch\nExpected: {s}\nActual: {s}", .{ server_signature, comparison_signature[2..] });
this.fail("The server did not return the correct signature", error.SASL_SIGNATURE_MISMATCH);
} else {

View File

@@ -260,35 +260,14 @@ devTest("hmr handles rapid consecutive edits", {
await Bun.sleep(1);
}
// Wait event-driven for "render 10" to appear. Intermediate renders may
// be skipped (watcher coalescing) and the final render may fire multiple
// times (duplicate reloads), so we just listen for any occurrence.
const finalRender = "render 10";
await new Promise<void>((resolve, reject) => {
const check = () => {
for (const msg of client.messages) {
if (typeof msg === "string" && msg.includes("HMR_ERROR")) {
cleanup();
reject(new Error("Unexpected HMR error message: " + msg));
return;
}
if (msg === finalRender) {
cleanup();
resolve();
return;
}
}
};
const cleanup = () => {
client.off("message", check);
};
client.on("message", check);
// Check messages already buffered.
check();
});
// Drain all buffered messages — intermediate renders and possible
// duplicates of the final render are expected and harmless.
client.messages.length = 0;
while (true) {
const message = await client.getStringMessage();
if (message === finalRender) break;
if (typeof message === "string" && message.includes("HMR_ERROR")) {
throw new Error("Unexpected HMR error message: " + message);
}
}
const hmrErrors = await client.js`return globalThis.__hmrErrors ? [...globalThis.__hmrErrors] : [];`;
if (hmrErrors.length > 0) {

View File

@@ -611,82 +611,6 @@ describe("Bun.Archive", () => {
// Very deep paths might fail on some systems - that's acceptable
}
});
test("directory entries with path traversal components cannot escape extraction root", async () => {
// Manually craft a tar archive containing directory entries with "../" traversal
// components in their pathnames. This tests that the extraction code uses the
// normalized path (which strips "..") rather than the raw pathname from the tarball.
function createTarHeader(
name: string,
size: number,
type: "0" | "5", // 0=file, 5=directory
): Uint8Array {
const header = new Uint8Array(512);
const enc = new TextEncoder();
header.set(enc.encode(name).slice(0, 100), 0);
header.set(enc.encode(type === "5" ? "0000755 " : "0000644 "), 100);
header.set(enc.encode("0000000 "), 108);
header.set(enc.encode("0000000 "), 116);
header.set(enc.encode(size.toString(8).padStart(11, "0") + " "), 124);
const mtime = Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0");
header.set(enc.encode(mtime + " "), 136);
header.set(enc.encode(" "), 148); // checksum placeholder
header[156] = type.charCodeAt(0);
header.set(enc.encode("ustar"), 257);
header[262] = 0;
header.set(enc.encode("00"), 263);
let checksum = 0;
for (let i = 0; i < 512; i++) checksum += header[i];
header.set(enc.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148);
return header;
}
const blocks: Uint8Array[] = [];
const enc = new TextEncoder();
// A legitimate directory
blocks.push(createTarHeader("safe_dir/", 0, "5"));
// A directory entry with traversal: "safe_dir/../../escaped_dir/"
// After normalization this becomes "escaped_dir" (safe),
// but the raw pathname resolves ".." via the kernel in mkdirat.
blocks.push(createTarHeader("safe_dir/../../escaped_dir/", 0, "5"));
// A normal file
const content = enc.encode("hello");
blocks.push(createTarHeader("safe_dir/file.txt", content.length, "0"));
blocks.push(content);
const pad = 512 - (content.length % 512);
if (pad < 512) blocks.push(new Uint8Array(pad));
// End-of-archive markers
blocks.push(new Uint8Array(1024));
const totalLen = blocks.reduce((s, b) => s + b.length, 0);
const tarball = new Uint8Array(totalLen);
let offset = 0;
for (const b of blocks) {
tarball.set(b, offset);
offset += b.length;
}
// Create a parent directory so we can check if "escaped_dir" appears outside extractDir
using parentDir = tempDir("archive-traversal-parent", {});
const extractPath = join(String(parentDir), "extract");
const { mkdirSync, existsSync } = require("fs");
mkdirSync(extractPath, { recursive: true });
const archive = new Bun.Archive(tarball);
await archive.extract(extractPath);
// The "escaped_dir" should NOT exist in the parent directory (outside extraction root)
const escapedOutside = join(String(parentDir), "escaped_dir");
expect(existsSync(escapedOutside)).toBe(false);
// The "safe_dir" should exist inside the extraction directory
expect(existsSync(join(extractPath, "safe_dir"))).toBe(true);
// The normalized "escaped_dir" may or may not exist inside extractPath
// (depending on whether normalization keeps it), but it must NOT be outside
});
});
describe("Archive.write()", () => {

View File

@@ -489,61 +489,6 @@ brr = 3
"zr": ["deedee"],
});
});
describe("truncated/invalid utf-8", () => {
test("bare continuation byte (0x80) should not crash", () => {
// 0x80 is a continuation byte without a leading byte
// utf8ByteSequenceLength returns 0, which must not hit unreachable
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0x80])]).toString("latin1");
// Should not crash - just parse gracefully
expect(() => parse(ini)).not.toThrow();
});
test("truncated 2-byte sequence at end of value", () => {
// 0xC0 is a 2-byte lead byte, but there's no continuation byte following
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xc0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 3-byte sequence at end of value", () => {
// 0xE0 is a 3-byte lead byte, but only 0 continuation bytes follow
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xe0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 3-byte sequence with 1 continuation byte at end", () => {
// 0xE0 is a 3-byte lead byte, but only 1 continuation byte follows
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xe0, 0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 4-byte sequence at end of value", () => {
// 0xF0 is a 4-byte lead byte, but only 0 continuation bytes follow
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 4-byte sequence with 1 continuation byte at end", () => {
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0, 0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 4-byte sequence with 2 continuation bytes at end", () => {
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0, 0x80, 0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 2-byte sequence in escaped context", () => {
// Backslash followed by a 2-byte lead byte at end of value
const ini = Buffer.concat([Buffer.from("key = \\"), Buffer.from([0xc0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("bare continuation byte in escaped context", () => {
const ini = Buffer.concat([Buffer.from("key = \\"), Buffer.from([0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
});
});
const wtf = {

View File

@@ -1,892 +0,0 @@
/**
* Direct port of ALL 85 tests from vendor/nodemailer/test/addressparser/addressparser-test.js
* Uses Bun.SMTPClient.parseAddress() static method.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Helper that runs parseAddress in a subprocess and returns the result
async function parse(input: string): Promise<any[]> {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `console.log(JSON.stringify(Bun.SMTPClient.parseAddress(${JSON.stringify(input)})))`],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
return JSON.parse(stdout.trim());
}
// Shorthand check: verify address field
function expectAddr(result: any, address: string, name: string = "") {
expect(result.address).toBe(address);
expect(result.name).toBe(name);
}
describe("addressparser (nodemailer port - 85 tests)", () => {
// Tests 1-6: Basic address formats
test("single address", async () => {
const r = await parse("andris@tr.ee");
expect(r).toHaveLength(1);
expectAddr(r[0], "andris@tr.ee");
});
test("multiple addresses", async () => {
const r = await parse("andris@tr.ee, andris@example.com");
expect(r).toHaveLength(2);
expectAddr(r[0], "andris@tr.ee");
expectAddr(r[1], "andris@example.com");
});
test("unquoted name", async () => {
const r = await parse("andris <andris@tr.ee>");
expect(r).toHaveLength(1);
expectAddr(r[0], "andris@tr.ee", "andris");
});
test("quoted name", async () => {
const r = await parse('"reinman, andris" <andris@tr.ee>');
expect(r).toHaveLength(1);
expectAddr(r[0], "andris@tr.ee", "reinman, andris");
});
test("quoted semicolons", async () => {
const r = await parse('"reinman; andris" <andris@tr.ee>');
expect(r).toHaveLength(1);
expectAddr(r[0], "andris@tr.ee", "reinman; andris");
});
test("unquoted name, unquoted address", async () => {
const r = await parse("andris andris@tr.ee");
expect(r).toHaveLength(1);
expectAddr(r[0], "andris@tr.ee", "andris");
});
// Tests 7-9: Groups
test("empty group", async () => {
const r = await parse("Undisclosed:;");
expect(r).toHaveLength(1);
expect(r[0].name).toBe("Undisclosed");
expect(r[0].group).toHaveLength(0);
});
test("address group", async () => {
const r = await parse("Disclosed:andris@tr.ee, andris@example.com;");
expect(r).toHaveLength(1);
expect(r[0].name).toBe("Disclosed");
expect(r[0].group).toHaveLength(2);
expect(r[0].group[0].address).toBe("andris@tr.ee");
expect(r[0].group[1].address).toBe("andris@example.com");
});
test("semicolon as delimiter", async () => {
const r = await parse("andris@tr.ee; andris@example.com;");
expect(r).toHaveLength(2);
expect(r[0].address).toBe("andris@tr.ee");
expect(r[1].address).toBe("andris@example.com");
});
// Test 10: Mixed group
test("mixed group", async () => {
const r = await parse(
"Test User <test.user@mail.ee>, Disclosed:andris@tr.ee, andris@example.com;,,,, Undisclosed:;",
);
expect(r).toHaveLength(3);
expectAddr(r[0], "test.user@mail.ee", "Test User");
expect(r[1].name).toBe("Disclosed");
expect(r[1].group).toHaveLength(2);
expect(r[2].name).toBe("Undisclosed");
expect(r[2].group).toHaveLength(0);
});
// Tests 13-16: Comments and edge cases
test("name from comment", async () => {
const r = await parse("andris@tr.ee (andris)");
expectAddr(r[0], "andris@tr.ee", "andris");
});
test("skip extra comment, use text name", async () => {
const r = await parse("andris@tr.ee (reinman) andris");
expect(r[0].address).toBe("andris@tr.ee");
expect(r[0].name).toBe("andris");
});
test("missing address", async () => {
const r = await parse("andris");
expectAddr(r[0], "", "andris");
});
test("apostrophe in name", async () => {
const r = await parse("O'Neill");
expectAddr(r[0], "", "O'Neill");
});
// Test 18: Invalid email
test("invalid email with double @", async () => {
const r = await parse("name@address.com@address2.com");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("name@address.com@address2.com");
});
// Test 19: Unexpected <
test("unexpected <", async () => {
const r = await parse("reinman > andris < test <andris@tr.ee>");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("andris@tr.ee");
});
// Security tests (RFC 5321/5322)
test("should not extract email from quoted local-part (security)", async () => {
const r = await parse('"xclow3n@gmail.com x"@internal.domain');
expect(r).toHaveLength(1);
expect(r[0].address).toContain("@internal.domain");
});
test("quoted local-part with attacker domain (security)", async () => {
const r = await parse('"user@attacker.com"@legitimate.com');
expect(r).toHaveLength(1);
expect(r[0].address).toContain("@legitimate.com");
});
test("multiple @ in quoted local-part (security)", async () => {
const r = await parse('"a@b@c"@example.com');
expect(r).toHaveLength(1);
expect(r[0].address).toBe("a@b@c@example.com");
});
// Edge cases
test("unclosed quote", async () => {
const r = await parse('"unclosed@example.com');
expect(r).toHaveLength(1);
expect(r[0].address).toBe("unclosed@example.com");
});
test("unclosed angle bracket", async () => {
const r = await parse("Name <user@example.com");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
test("unclosed comment", async () => {
const r = await parse("user@example.com (comment");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
test("empty string", async () => {
const r = await parse("");
expect(r).toHaveLength(0);
});
test("whitespace only", async () => {
const r = await parse(" ");
expect(r).toHaveLength(0);
});
test("empty angle brackets", async () => {
const r = await parse("<>");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("");
});
test("special chars in local-part", async () => {
for (const addr of [
"user+tag@example.com",
"user.name@example.com",
"user_name@example.com",
"user-name@example.com",
]) {
const r = await parse(addr);
expect(r[0].address).toBe(addr);
}
});
test("leading/trailing whitespace", async () => {
const r = await parse(" user@example.com ");
expect(r[0].address).toBe("user@example.com");
});
test("comment before address", async () => {
const r = await parse("(comment)user@example.com");
expect(r[0].address).toBe("user@example.com");
});
test("comment after address without space", async () => {
const r = await parse("user@example.com(comment)");
expect(r[0].address).toBe("user@example.com");
});
test("multiple consecutive delimiters", async () => {
const r = await parse("a@example.com,,,b@example.com");
expect(r).toHaveLength(2);
expect(r[0].address).toBe("a@example.com");
expect(r[1].address).toBe("b@example.com");
});
test("mixed quotes and unquoted text", async () => {
const r = await parse('"quoted" unquoted@example.com');
expect(r[0].name).toBe("quoted");
expect(r[0].address).toBe("unquoted@example.com");
});
test("very long local-part", async () => {
const addr = "a".repeat(100) + "@example.com";
const r = await parse(addr);
expect(r[0].address).toBe(addr);
});
test("very long domain", async () => {
const addr = "user@" + "a".repeat(100) + ".com";
const r = await parse(addr);
expect(r[0].address).toBe(addr);
});
test("double @ (malformed)", async () => {
const r = await parse("user@@example.com");
expect(r).toHaveLength(1);
expect(r[0].address).toContain("@@");
});
test("address with only name, no email", async () => {
const r = await parse("John Doe");
expect(r[0].name).toBe("John Doe");
expect(r[0].address).toBe("");
});
// Unicode tests
test("unicode in display name", async () => {
const r = await parse("Jüri Õunapuu <juri@example.com>");
expectAddr(r[0], "juri@example.com", "Jüri Õunapuu");
});
test("emoji in display name", async () => {
const r = await parse("🤖 Robot <robot@example.com>");
expectAddr(r[0], "robot@example.com", "🤖 Robot");
});
test("unicode domain (IDN)", async () => {
const r = await parse("user@münchen.de");
expect(r[0].address).toBe("user@münchen.de");
});
test("CJK characters in name", async () => {
const r = await parse("田中太郎 <tanaka@example.jp>");
expectAddr(r[0], "tanaka@example.jp", "田中太郎");
});
// Malformed input
test("address with no domain", async () => {
const r = await parse("user@");
expect(r[0].address).toBe("user@");
});
test("address with no local part", async () => {
const r = await parse("@example.com");
expect(r[0].address).toContain("@example.com");
});
test("mixed case in domain", async () => {
const r = await parse("user@Example.COM");
expect(r[0].address).toBe("user@Example.COM");
});
// Subdomain tests
test("multiple subdomains", async () => {
const r = await parse("user@mail.server.company.example.com");
expect(r[0].address).toBe("user@mail.server.company.example.com");
});
test("numeric subdomains", async () => {
const r = await parse("user@123.456.example.com");
expect(r[0].address).toBe("user@123.456.example.com");
});
test("hyphenated subdomains", async () => {
const r = await parse("user@mail-server.example.com");
expect(r[0].address).toBe("user@mail-server.example.com");
});
// IP address domains
test("IPv4 address as domain", async () => {
const r = await parse("user@[192.168.1.1]");
expect(r[0].address).toBe("user@[192.168.1.1]");
});
// Group edge cases
test("group with only spaces", async () => {
const r = await parse("EmptyGroup: ;");
expect(r[0].name).toBe("EmptyGroup");
expect(r[0].group).toHaveLength(0);
});
test("group with invalid addresses", async () => {
const r = await parse("Group:not-an-email, another-invalid;");
expect(r[0].name).toBe("Group");
expect(r[0].group).toHaveLength(2);
});
test("group name with special chars", async () => {
const r = await parse("Group-Name_123:user@example.com;");
expect(r[0].name).toBe("Group-Name_123");
expect(r[0].group).toHaveLength(1);
});
test("quoted group name", async () => {
const r = await parse('"My Group":user@example.com;');
expect(r[0].name).toBe("My Group");
expect(r[0].group).toHaveLength(1);
});
// Comment edge cases
test("multiple comments", async () => {
const r = await parse("(comment1)user@example.com(comment2)");
expect(r[0].address).toBe("user@example.com");
});
test("empty comment", async () => {
const r = await parse("user@example.com()");
expect(r[0].address).toBe("user@example.com");
});
test("comment with special characters", async () => {
const r = await parse("user@example.com (comment with @#$%)");
expect(r[0].address).toBe("user@example.com");
});
// Nested group tests
test("deeply nested groups", async () => {
const r = await parse("Outer:Inner:deep@example.com;;");
expect(r).toHaveLength(1);
expect(r[0].name).toBe("Outer");
expect(r[0].group).toBeDefined();
expect(r[0].group.length).toBe(1);
expect(r[0].group[0].address).toBe("deep@example.com");
});
test("normal nested group preserved", async () => {
const r = await parse("Outer: Inner: deep@example.com; ;");
expect(r).toHaveLength(1);
expect(r[0].name).toBe("Outer");
expect(r[0].group).toBeDefined();
expect(r[0].group[0].address).toBe("deep@example.com");
});
// Performance: many @ symbols (DoS protection)
test("many @ symbols (DoS protection)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const input = "@".repeat(100);
const start = Date.now();
const r = Bun.SMTPClient.parseAddress(input);
const elapsed = Date.now() - start;
console.log(JSON.stringify({ ok: elapsed < 1000, len: r.length }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(stdout.trim()).ok).toBe(true);
expect(exitCode).toBe(0);
});
// Performance: many consecutive delimiters
test("many consecutive delimiters (DoS protection)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const input = "a@b.com" + ",".repeat(100) + "c@d.com";
const start = Date.now();
const r = Bun.SMTPClient.parseAddress(input);
const elapsed = Date.now() - start;
console.log(JSON.stringify({ ok: elapsed < 1000, len: r.length }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.ok).toBe(true);
expect(d.len).toBe(2);
expect(exitCode).toBe(0);
});
// Deep nesting DoS protection
test("depth 3000 nesting (DoS protection)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let input = "";
for (let i = 0; i < 3000; i++) input += "g" + i + ": ";
input += "user@example.com;";
const start = Date.now();
const r = Bun.SMTPClient.parseAddress(input);
const elapsed = Date.now() - start;
console.log(JSON.stringify({ ok: elapsed < 2000, hasResult: r.length >= 1 }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.ok).toBe(true);
expect(d.hasResult).toBe(true);
expect(exitCode).toBe(0);
});
// ---- Missing tests 11-85 that weren't ported yet ----
// 12: semicolon as delimiter should not break group parsing
test("semicolon as delimiter should not break group parsing", async () => {
const r = await parse(
"Test User <test.user@mail.ee>; Disclosed:andris@tr.ee, andris@example.com;,,,, Undisclosed:; bob@example.com;",
);
expect(r).toHaveLength(4);
expect(r[0].address).toBe("test.user@mail.ee");
expect(r[1].name).toBe("Disclosed");
expect(r[1].group).toHaveLength(2);
expect(r[2].name).toBe("Undisclosed");
expect(r[2].group).toHaveLength(0);
expect(r[3].address).toBe("bob@example.com");
});
// 17: bad input with unescaped colon
test("bad input with unescaped colon", async () => {
const r = await parse("FirstName Surname-WithADash :: Company <firstname@company.com>");
expect(r).toHaveLength(1);
expect(r[0].group).toBeDefined();
});
// 20: escapes
test("escapes in quoted string", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const r = Bun.SMTPClient.parseAddress('"Firstname \\\\" \\\\\\\\\\\\, Lastname \\\\(Test\\\\)" test@example.com'); console.log(JSON.stringify({ hasAddr: r[0]?.address === "test@example.com" }));`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(stdout.trim()).hasAddr).toBe(true);
expect(exitCode).toBe(0);
});
// 21: quoted usernames
test("quoted usernames", async () => {
const r = await parse('"test@subdomain.com"@example.com');
expect(r).toHaveLength(1);
expect(r[0].address).toBe("test@subdomain.com@example.com");
});
// 25: quoted local-part with angle brackets
test("quoted local-part with angle brackets", async () => {
const r = await parse('Name <"user@domain.com"@example.com>');
expect(r).toHaveLength(1);
expect(r[0].name).toBe("Name");
});
// 26: escaped quotes in quoted string
test("escaped quotes in quoted string", async () => {
// Use JSON to pass the tricky string to avoid shell escaping issues
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const input = JSON.parse('"\\\\\"test\\\\\\\\\\\\"quote\\\\\\"@example.com"'); const r = Bun.SMTPClient.parseAddress(input); console.log(JSON.stringify({ len: r.length, hasAt: (r[0]?.address || r[0]?.name || "").includes("@") }));`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.len).toBe(1);
expect(d.hasAt).toBe(true);
expect(exitCode).toBe(0);
});
// 27: escaped backslashes
test("escaped backslashes", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
String.raw`const r = Bun.SMTPClient.parseAddress('"test\\backslash"@example.com'); console.log(JSON.stringify({ has: r[0]?.address?.includes("@example.com") }));`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(stdout.trim()).has).toBe(true);
expect(exitCode).toBe(0);
});
// 45: nested comments
test("nested comments", async () => {
const r = await parse("user@example.com (outer (nested) comment)");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
// 46: quoted text with spaces (security)
test("quoted text with spaces (security)", async () => {
const r = await parse('"evil@attacker.com more stuff"@legitimate.com');
expect(r).toHaveLength(1);
expect(r[0].address).toContain("@legitimate.com");
});
// 55: multiple angle brackets
test("multiple angle brackets", async () => {
const r = await parse("Name <<user@example.com>>");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
// 59: tabs
test("tab characters", async () => {
const r = await parse("user@example.com\t\tother@example.com");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
// 60: newlines
test("newlines in input", async () => {
const r = await parse("user@example.com\nother@example.com");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
// 61: CRLF
test("CRLF line endings", async () => {
const r = await parse("user@example.com\r\nother@example.com");
expect(r).toHaveLength(1);
expect(r[0].address).toBe("user@example.com");
});
// 74: very long address list
test("1000 addresses performance", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const addrs = Array.from({length: 1000}, (_, i) => "user" + i + "@example.com").join(", ");
const start = Date.now();
const r = Bun.SMTPClient.parseAddress(addrs);
const elapsed = Date.now() - start;
console.log(JSON.stringify({ ok: elapsed < 5000, len: r.length }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.ok).toBe(true);
// Our parser has a 512-entry buffer limit, so it may cap at 512
expect(d.len).toBeGreaterThan(100);
expect(exitCode).toBe(0);
});
// 75: deeply nested quotes
test("deeply nested quotes", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
String.raw`const r = Bun.SMTPClient.parseAddress('"test\"nested\"quotes"@example.com'); console.log(JSON.stringify({ has: r[0]?.address?.includes("@example.com") }));`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(stdout.trim()).has).toBe(true);
expect(exitCode).toBe(0);
});
// 77-84: Deep nesting DoS protection (depth 10, 50, 100, 3000, 10000, multiple, mixed, normal)
test("depth 10 nesting", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let input = ""; for (let i = 0; i < 10; i++) input += "g" + i + ": ";
input += "user@example.com;";
const r = Bun.SMTPClient.parseAddress(input);
console.log(JSON.stringify({ len: r.length, name: r[0]?.name, hasGroup: !!r[0]?.group, memberAddr: r[0]?.group?.[0]?.address }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.len).toBe(1);
expect(d.name).toBe("g0");
expect(d.hasGroup).toBe(true);
expect(d.memberAddr).toBe("user@example.com");
expect(exitCode).toBe(0);
});
test("depth 50 nesting", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let input = ""; for (let i = 0; i < 50; i++) input += "g" + i + ": ";
input += "user@example.com;";
const r = Bun.SMTPClient.parseAddress(input);
console.log(JSON.stringify({ len: r.length, hasGroup: !!r[0]?.group, memberAddr: r[0]?.group?.[0]?.address }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.len).toBe(1);
expect(d.hasGroup).toBe(true);
expect(d.memberAddr).toBe("user@example.com");
expect(exitCode).toBe(0);
});
test("depth 100 nesting (truncated safely)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let input = ""; for (let i = 0; i < 100; i++) input += "g" + i + ": ";
input += "user@example.com;";
const r = Bun.SMTPClient.parseAddress(input);
console.log(JSON.stringify({ len: r.length, hasGroup: !!r[0]?.group }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.len).toBe(1);
expect(d.hasGroup).toBe(true);
expect(exitCode).toBe(0);
});
test("depth 10000 nesting (DoS protection)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let input = ""; for (let i = 0; i < 10000; i++) input += "g" + i + ": ";
input += "user@example.com;";
const start = Date.now();
const r = Bun.SMTPClient.parseAddress(input);
const elapsed = Date.now() - start;
console.log(JSON.stringify({ ok: elapsed < 2000, isArray: Array.isArray(r) }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.ok).toBe(true);
expect(d.isArray).toBe(true);
expect(exitCode).toBe(0);
});
test("multiple deeply nested groups", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let g = ""; for (let i = 0; i < 100; i++) g += "g" + i + ": ";
g += "user@example.com;";
const input = g + ", " + g;
const r = Bun.SMTPClient.parseAddress(input);
console.log(JSON.stringify({ len: r.length }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(stdout.trim()).len).toBe(2);
expect(exitCode).toBe(0);
});
// 11: flatten mixed group
test("flatten mixed group", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const r = Bun.SMTPClient.parseAddress(
"Test User <test.user@mail.ee>, Disclosed:andris@tr.ee, andris@example.com;,,,, Undisclosed:; bob@example.com BOB;",
{ flatten: true }
);
console.log(JSON.stringify(r));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const r = JSON.parse(stdout.trim());
expect(r).toHaveLength(4);
expect(r[0].address).toBe("test.user@mail.ee");
expect(r[1].address).toBe("andris@tr.ee");
expect(r[2].address).toBe("andris@example.com");
expect(r[3].address).toBe("bob@example.com");
expect(exitCode).toBe(0);
});
// 47: flatten nested groups
test("flatten nested groups", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const r = Bun.SMTPClient.parseAddress("Group1:a@b.com, Group2:c@d.com;;", { flatten: true });
const addrs = r.map(x => x.address).filter(Boolean);
console.log(JSON.stringify({ has_a: addrs.includes("a@b.com"), has_c: addrs.includes("c@d.com") }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.has_a).toBe(true);
expect(d.has_c).toBe(true);
expect(exitCode).toBe(0);
});
// 48: flatten deeply nested groups
test("flatten deeply nested groups", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const r = Bun.SMTPClient.parseAddress("Outer:Inner:deep@example.com;;", { flatten: true });
console.log(JSON.stringify({ len: r.length, addr: r[0]?.address }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.len).toBe(1);
expect(d.addr).toBe("deep@example.com");
expect(exitCode).toBe(0);
});
// 49: flatten multiple nested groups at same level
test("flatten multiple nested groups at same level", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const r = Bun.SMTPClient.parseAddress("Main:Sub1:a@b.com;, Sub2:c@d.com;;", { flatten: true });
const addrs = r.map(x => x.address).filter(Boolean);
console.log(JSON.stringify({ has_a: addrs.includes("a@b.com"), has_c: addrs.includes("c@d.com") }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.has_a).toBe(true);
expect(d.has_c).toBe(true);
expect(exitCode).toBe(0);
});
// 50: mixed nested and regular addresses in group (flattened)
test("flatten mixed nested and regular in group", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const r = Bun.SMTPClient.parseAddress("Group:x@y.com, Nested:a@b.com;, z@w.com;", { flatten: true });
const addrs = r.map(x => x.address).filter(Boolean);
console.log(JSON.stringify({ has_x: addrs.includes("x@y.com"), has_a: addrs.includes("a@b.com"), has_z: addrs.includes("z@w.com") }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.has_x).toBe(true);
expect(d.has_a).toBe(true);
expect(d.has_z).toBe(true);
expect(exitCode).toBe(0);
});
// 85: flatten with deep nesting
test("flatten with deep nesting (DoS protection)", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let input = ""; for (let i = 0; i < 100; i++) input += "g" + i + ": ";
input += "user@example.com;";
const r = Bun.SMTPClient.parseAddress(input, { flatten: true });
console.log(JSON.stringify({ isArray: Array.isArray(r), len: r.length }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.isArray).toBe(true);
expect(exitCode).toBe(0);
});
test("mixed normal and deeply nested", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let g = ""; for (let i = 0; i < 200; i++) g += "g" + i + ": ";
g += "user@example.com;";
const input = "normal@example.com, " + g + ", another@test.com";
const r = Bun.SMTPClient.parseAddress(input);
console.log(JSON.stringify({ len: r.length, first: r[0]?.address, last: r[r.length-1]?.address }));
`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const d = JSON.parse(stdout.trim());
expect(d.len).toBe(3);
expect(d.first).toBe("normal@example.com");
expect(d.last).toBe("another@test.com");
expect(exitCode).toBe(0);
});
});

View File

@@ -1,553 +0,0 @@
/**
* Tests for previously untested code paths in the SMTP client.
* Each test exercises a specific branch that had zero coverage.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
// Mock server that can be configured with various behaviors
const MOCK = `
function mock(opts = {}) {
let msgCount = 0;
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) {
const sess = { cmds: [], msg: "", inData: false, buf: "" };
sessions.push(sess); s.data = sess;
if (opts.greeting === false) return; // Don't send greeting (for timeout tests)
s.write((opts.greeting || "220 mock SMTP") + "\\r\\n");
},
data(s, raw) {
const t = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) {
sess.buf += t;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false; msgCount++;
sess.msg = sess.buf.split("\\r\\n.\\r\\n")[0]; sess.buf = "";
if (opts.rejectData) { s.write("554 Message rejected\\r\\n"); }
else { s.write("250 OK msg #" + msgCount + "\\r\\n"); }
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
sess.cmds.push(l);
if (l.startsWith("EHLO")) {
if (opts.rejectEhlo) { s.write("502 Not implemented\\r\\n"); }
else if (opts.ehlo421) { s.write("421 Service closing\\r\\n"); s.end(); }
else {
let resp = "250-mock\\r\\n";
if (opts.ehloLines) resp += opts.ehloLines.map(l => "250-" + l + "\\r\\n").join("");
if (opts.authMethods) resp += "250-AUTH " + opts.authMethods + "\\r\\n";
resp += "250 OK\\r\\n";
s.write(resp);
}
}
else if (l.startsWith("HELO")) s.write("250 OK\\r\\n");
else if (l.startsWith("AUTH PLAIN")) {
if (opts.authFail) s.write("535 Auth failed\\r\\n");
else s.write("235 OK\\r\\n");
}
else if (l.startsWith("AUTH LOGIN")) {
sess.authMethod = "LOGIN";
s.write("334 VXNlcm5hbWU6\\r\\n");
}
else if (l.startsWith("AUTH CRAM-MD5")) {
if (opts.cramChallenge) s.write("334 " + opts.cramChallenge + "\\r\\n");
else s.write("334 dGVzdCBjaGFsbGVuZ2U=\\r\\n");
}
else if (l.startsWith("AUTH XOAUTH2")) {
sess.xoauthToken = l.substring(13);
s.write("235 OK\\r\\n");
}
else if (l.startsWith("MAIL FROM:")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT TO:")) {
const addr = l.match(/<(.+?)>/)?.[1];
if (opts.rejectTo === addr) s.write("550 Rejected\\r\\n");
else s.write("250 OK\\r\\n");
}
else if (l === "DATA") {
if (opts.rejectDataCmd) { s.write("421 Too much mail\\r\\n"); }
else { sess.inData = true; sess.buf = ""; s.write("354 Go\\r\\n"); }
}
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
else {
// For AUTH LOGIN username/password responses
if (sess.authMethod === "LOGIN") {
sess.authMethod = "LOGIN_PASS";
s.write("334 UGFzc3dvcmQ6\\r\\n");
} else if (sess.authMethod === "LOGIN_PASS") {
if (opts.authFail) s.write("535 Auth failed\\r\\n");
else s.write("235 OK\\r\\n");
sess.authMethod = null;
} else if (opts.cramResponse) {
// CRAM-MD5 response
s.write("235 OK\\r\\n");
} else {
s.write("235 OK\\r\\n");
}
}
}
},
},
});
return { server, sessions, port: server.port, getMsgCount: () => msgCount };
}
`;
describe("Error paths", () => {
test("should reject on non-220 server greeting", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ greeting: "421 Too busy" });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log("ERROR: should have thrown");
} catch(e) {
console.log(JSON.stringify({ code: e.code }));
}
c.close(); server.stop();
`);
expect(JSON.parse(stdout).code).toBe("EPROTOCOL");
expect(exitCode).toBe(0);
});
test("should reject when DATA command is rejected", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ rejectDataCmd: true });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log("ERROR: should have thrown");
} catch(e) {
console.log(JSON.stringify({ code: e.code, msg: e.message }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.msg).toContain("DATA command rejected");
expect(exitCode).toBe(0);
});
test("should reject when message body is rejected after transmission", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ rejectData: true });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log("ERROR: should have thrown");
} catch(e) {
console.log(JSON.stringify({ code: e.code, msg: e.message }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.code).toBe("EMESSAGE");
expect(d.msg).toContain("Message rejected");
expect(exitCode).toBe(0);
});
test("should reject when server sends 421 during EHLO", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ ehlo421: true });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log("ERROR: should have thrown");
} catch(e) {
console.log(JSON.stringify({ code: e.code }));
}
c.close(); server.stop();
`);
expect(JSON.parse(stdout).code).toBeDefined();
expect(exitCode).toBe(0);
});
test("should reject when message exceeds server SIZE limit", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ ehloLines: ["SIZE 50"] });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "A".repeat(200) });
console.log("ERROR: should have thrown");
} catch(e) {
console.log(JSON.stringify({ code: e.code, msg: e.message }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.code).toBe("EMESSAGE");
expect(d.msg).toContain("size exceeds");
expect(exitCode).toBe(0);
});
});
describe("Auth methods", () => {
test("should force AUTH LOGIN when method specified", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock({ authMethods: "PLAIN LOGIN" });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, auth: { user: "u", pass: "p", method: "LOGIN" } });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
const usedLogin = sessions[0].cmds.some(c => c.startsWith("AUTH LOGIN"));
const usedPlain = sessions[0].cmds.some(c => c.startsWith("AUTH PLAIN"));
console.log(JSON.stringify({ usedLogin, usedPlain }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.usedLogin).toBe(true);
expect(d.usedPlain).toBe(false);
expect(exitCode).toBe(0);
});
test("should send pre-built XOAUTH2 token directly", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock({ authMethods: "XOAUTH2" });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, auth: { user: "u", xoauth2: "my-prebuilt-token" } });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log(JSON.stringify({ token: sessions[0].xoauthToken }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).token).toBe("my-prebuilt-token");
expect(exitCode).toBe(0);
});
});
describe("Raw message sending", () => {
test("should send raw string message without MIME building", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", raw: "Subject: Raw\\r\\n\\r\\nRaw body here" });
console.log(JSON.stringify({
hasRaw: sessions[0].msg.includes("Raw body here"),
hasSubject: sessions[0].msg.includes("Subject: Raw"),
noMime: !sessions[0].msg.includes("MIME-Version"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasRaw).toBe(true);
expect(d.hasSubject).toBe(true);
expect(d.noMime).toBe(true);
expect(exitCode).toBe(0);
});
test("should send raw Buffer/ArrayBuffer message", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const rawBytes = new TextEncoder().encode("Subject: Buffer\\r\\n\\r\\nBuffer body");
await c.send({ from: "a@b.com", to: "c@d.com", raw: rawBytes });
console.log(JSON.stringify({ hasBody: sessions[0].msg.includes("Buffer body") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasBody).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("verify() method", () => {
test("should verify connection with auth", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock({ authMethods: "PLAIN" });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, auth: { user: "u", pass: "p" } });
const result = await c.verify();
const didAuth = sessions[0].cmds.some(c => c.startsWith("AUTH"));
console.log(JSON.stringify({ result, didAuth }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.result).toBe(true);
expect(d.didAuth).toBe(true);
expect(exitCode).toBe(0);
});
test("should return true immediately if already connected", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
const result = await c.verify();
console.log(JSON.stringify({ result }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).result).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("connected/secure getters", () => {
test("should return connected=true after successful send", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const before = c.connected;
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
const after = c.connected;
c.close();
const closed = c.connected;
console.log(JSON.stringify({ before, after, closed }));
server.stop();
`);
const d = JSON.parse(stdout);
expect(d.before).toBe(false);
expect(d.after).toBe(true);
expect(d.closed).toBe(false);
expect(exitCode).toBe(0);
});
test("should return secure=false for plain connection", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log(JSON.stringify({ secure: c.secure }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).secure).toBe(false);
expect(exitCode).toBe(0);
});
});
describe("Bun.SMTPClient.parseAddress()", () => {
test("should parse simple address", async () => {
const { stdout, exitCode } = await run(`
const r = Bun.SMTPClient.parseAddress("John <john@example.com>");
console.log(JSON.stringify(r));
`);
const d = JSON.parse(stdout);
expect(d).toEqual([{ name: "John", address: "john@example.com" }]);
expect(exitCode).toBe(0);
});
test("should parse comma-separated addresses", async () => {
const { stdout, exitCode } = await run(`
const r = Bun.SMTPClient.parseAddress("a@b.com, c@d.com");
console.log(JSON.stringify(r));
`);
const d = JSON.parse(stdout);
expect(d).toHaveLength(2);
expect(d[0].address).toBe("a@b.com");
expect(d[1].address).toBe("c@d.com");
expect(exitCode).toBe(0);
});
test("should flatten groups with { flatten: true }", async () => {
const { stdout, exitCode } = await run(`
const r = Bun.SMTPClient.parseAddress("Group: a@b.com, c@d.com;", { flatten: true });
console.log(JSON.stringify(r));
`);
const d = JSON.parse(stdout);
expect(d).toHaveLength(2);
expect(d[0].address).toBe("a@b.com");
expect(d[1].address).toBe("c@d.com");
expect(exitCode).toBe(0);
});
});
describe("MIME features", () => {
test("should include MAIL FROM SIZE parameter when server advertises SIZE", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock({ ehloLines: ["SIZE 10485760"] });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
const mailFrom = sessions[0].cmds.find(c => c.startsWith("MAIL FROM:"));
console.log(JSON.stringify({ hasSizeParam: mailFrom?.includes("SIZE=") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSizeParam).toBe(true);
expect(exitCode).toBe(0);
});
test("should include custom headers in message", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "hi",
headers: { "X-Custom-Header": "custom-value", "X-Tracking-ID": "12345" },
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasCustom: msg.includes("X-Custom-Header: custom-value"),
hasTracking: msg.includes("X-Tracking-ID: 12345"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasCustom).toBe(true);
expect(d.hasTracking).toBe(true);
expect(exitCode).toBe(0);
});
test("should handle multiple list headers", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "hi",
list: {
unsubscribe: "https://example.com/unsubscribe",
help: "https://example.com/help",
},
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasUnsub: msg.includes("List-Unsubscribe:"),
hasHelp: msg.includes("List-Help:"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasUnsub).toBe(true);
expect(d.hasHelp).toBe(true);
expect(exitCode).toBe(0);
});
test("should handle iCalendar with text only (no html)", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com",
text: "See attached calendar",
icalEvent: "BEGIN:VCALENDAR\\r\\nEND:VCALENDAR",
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasMultipartAlt: msg.includes("multipart/alternative"),
hasCalendar: msg.includes("text/calendar"),
hasPlain: msg.includes("text/plain"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasMultipartAlt).toBe(true);
expect(d.hasCalendar).toBe(true);
expect(d.hasPlain).toBe(true);
expect(exitCode).toBe(0);
});
test("should include attachment with Content-Disposition", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "hi",
attachments: [{ filename: "test.pdf", content: "data" }],
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasAttachment: msg.includes("Content-Disposition: attachment"),
hasFilename: msg.includes("test.pdf"),
hasMultipart: msg.includes("multipart/mixed"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasAttachment).toBe(true);
expect(d.hasFilename).toBe(true);
expect(d.hasMultipart).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Connection lifecycle", () => {
test("should handle connection timeout when server never responds", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ greeting: false }); // Server accepts but never sends greeting
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, connectionTimeout: 500 });
const start = Date.now();
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log(JSON.stringify({ threw: false }));
} catch(e) {
const elapsed = Date.now() - start;
console.log(JSON.stringify({ threw: true, code: e.code, timedOut: elapsed >= 400 && elapsed < 5000 }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.threw).toBe(true);
expect(d.code).toBe("ETIMEDOUT");
expect(d.timedOut).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Well-known services", () => {
test("should configure from service name", async () => {
const { stdout, exitCode } = await run(`
// Just verify construction with a well-known service doesn't throw
const c = new Bun.SMTPClient({ service: "Gmail", auth: { user: "u", pass: "p" } });
console.log(JSON.stringify({ created: true }));
c.close();
`);
expect(JSON.parse(stdout).created).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Envelope override", () => {
test("should use explicit envelope instead of message headers", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "header-from@example.com",
to: "header-to@example.com",
text: "hi",
envelope: {
from: "envelope-from@example.com",
to: ["envelope-to@example.com"],
},
});
const mailFrom = sessions[0].cmds.find(c => c.startsWith("MAIL FROM:"));
const rcptTo = sessions[0].cmds.find(c => c.startsWith("RCPT TO:"));
console.log(JSON.stringify({
envFrom: mailFrom?.includes("envelope-from@example.com"),
envTo: rcptTo?.includes("envelope-to@example.com"),
notHeaderFrom: !mailFrom?.includes("header-from@example.com"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.envFrom).toBe(true);
expect(d.envTo).toBe(true);
expect(d.notHeaderFrom).toBe(true);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,558 +0,0 @@
/**
* Deep verification tests - actually check that the SMTP client produces correct output.
* These tests verify protocol ordering, message structure, edge cases, and security.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
// Mock server that captures FULL command sequence and message
const MOCK = `
function mock(opts = {}) {
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) {
const sess = { cmds: [], msg: "", inData: false, buf: "" };
sessions.push(sess); s.data = sess;
s.write("220 mock SMTP ready\\r\\n");
},
data(s, raw) {
const t = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) {
sess.buf += t;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false;
sess.msg = sess.buf.slice(0, sess.buf.indexOf("\\r\\n.\\r\\n"));
sess.buf = "";
s.write("250 OK\\r\\n");
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
sess.cmds.push(l);
if (l.startsWith("EHLO") || l.startsWith("LHLO")) s.write("250-mock\\r\\n250-AUTH PLAIN LOGIN CRAM-MD5\\r\\n250-SIZE 10485760\\r\\n250 OK\\r\\n");
else if (l.startsWith("MAIL FROM:")) {
if (opts.rejectSender) s.write("550 Sender rejected\\r\\n");
else s.write("250 OK\\r\\n");
}
else if (l.startsWith("RCPT TO:")) {
const addr = l.match(/<(.+?)>/)?.[1];
if (opts.rejectTo?.includes(addr)) s.write("550 Recipient rejected\\r\\n");
else s.write("250 OK\\r\\n");
}
else if (l === "DATA") { sess.inData = true; sess.buf = ""; s.write("354 Start mail input\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
else if (l.startsWith("AUTH PLAIN")) {
sess.cmds.push("(auth-plain)");
s.write("235 Authentication successful\\r\\n");
}
else if (l.startsWith("AUTH LOGIN")) {
s.write("334 VXNlcm5hbWU6\\r\\n"); // Username:
}
else s.write("235 OK\\r\\n"); // catchall for AUTH LOGIN steps
}
},
},
});
return { server, sessions, port: server.port };
}
`;
describe("Protocol command ordering", () => {
test("should send commands in correct SMTP order: EHLO → MAIL FROM → RCPT TO → DATA", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "sender@test.com", to: "rcpt@test.com", text: "hello" });
const cmds = sessions[0].cmds;
console.log(JSON.stringify(cmds));
c.close(); server.stop();
`);
const cmds: string[] = JSON.parse(stdout);
// Verify ordering
expect(cmds[0]).toMatch(/^EHLO /);
const mailFromIdx = cmds.findIndex(c => c.startsWith("MAIL FROM:"));
const rcptToIdx = cmds.findIndex(c => c.startsWith("RCPT TO:"));
const dataIdx = cmds.findIndex(c => c === "DATA");
expect(mailFromIdx).toBeGreaterThan(0); // after EHLO
expect(rcptToIdx).toBeGreaterThan(mailFromIdx);
expect(dataIdx).toBeGreaterThan(rcptToIdx);
// Verify exact addresses
expect(cmds[mailFromIdx]).toMatch(/^MAIL FROM:<sender@test\.com>/);
expect(cmds[rcptToIdx]).toBe("RCPT TO:<rcpt@test.com>");
expect(exitCode).toBe(0);
});
test("should send AUTH before MAIL FROM when credentials provided", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, auth: { user: "u", pass: "p" } });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
const cmds = sessions[0].cmds;
console.log(JSON.stringify(cmds));
c.close(); server.stop();
`);
const cmds: string[] = JSON.parse(stdout);
const authIdx = cmds.findIndex(c => c.startsWith("AUTH "));
const mailFromIdx = cmds.findIndex(c => c.startsWith("MAIL FROM:"));
expect(authIdx).toBeGreaterThan(0); // after EHLO
expect(mailFromIdx).toBeGreaterThan(authIdx); // after AUTH
expect(exitCode).toBe(0);
});
test("should send RSET between sequential sends (connection reuse)", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "x@y.com", text: "first" });
await c.send({ from: "a@b.com", to: "p@q.com", text: "second" });
const cmds = sessions[0].cmds;
console.log(JSON.stringify(cmds));
c.close(); server.stop();
`);
const cmds: string[] = JSON.parse(stdout);
const rsetIdx = cmds.findIndex(c => c === "RSET");
expect(rsetIdx).toBeGreaterThan(0);
// Second MAIL FROM should come after RSET
const mailFroms = cmds.filter(c => c.startsWith("MAIL FROM:"));
expect(mailFroms).toHaveLength(2);
const secondMailIdx = cmds.indexOf(mailFroms[1]);
expect(secondMailIdx).toBeGreaterThan(rsetIdx);
expect(exitCode).toBe(0);
});
});
describe("Message structure verification", () => {
test("plain text email should have correct headers and body", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "sender@test.com", to: "rcpt@test.com", subject: "Test Subject", text: "Hello World" });
console.log(JSON.stringify(sessions[0].msg));
c.close(); server.stop();
`);
const msg: string = JSON.parse(stdout);
// Required RFC 5322 headers
expect(msg).toContain("From: sender@test.com");
expect(msg).toContain("To: rcpt@test.com");
expect(msg).toContain("Subject: Test Subject");
expect(msg).toContain("MIME-Version: 1.0");
expect(msg).toMatch(/Message-ID: <.+@.+>/);
expect(msg).toMatch(/Date: /);
// Body
expect(msg).toContain("Content-Type: text/plain");
expect(msg).toContain("Hello World");
// Headers and body separated by blank line
const parts = msg.split("\r\n\r\n");
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(exitCode).toBe(0);
});
test("multipart/alternative should have text before html with proper boundaries", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "Plain version", html: "<b>HTML version</b>" });
console.log(JSON.stringify(sessions[0].msg));
c.close(); server.stop();
`);
const msg: string = JSON.parse(stdout);
expect(msg).toContain("multipart/alternative");
// Extract boundary from Content-Type header
const boundaryMatch = msg.match(/boundary="?([^";\r\n]+)"?/);
expect(boundaryMatch).not.toBeNull();
const boundary = boundaryMatch![1];
// Text part should come before HTML part (RFC 2046 convention)
const textIdx = msg.indexOf("text/plain");
const htmlIdx = msg.indexOf("text/html");
expect(textIdx).toBeGreaterThan(-1);
expect(htmlIdx).toBeGreaterThan(textIdx);
// Both parts should contain actual content
expect(msg).toContain("Plain version");
expect(msg).toContain("<b>HTML version</b>");
// Boundary delimiters should be present
expect(msg).toContain("--" + boundary);
expect(msg).toContain("--" + boundary + "--");
expect(exitCode).toBe(0);
});
test("attachment should be base64-encoded with proper Content-Disposition", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "See attached",
attachments: [{ filename: "test.txt", content: "Hello from attachment" }],
});
console.log(JSON.stringify(sessions[0].msg));
c.close(); server.stop();
`);
const msg: string = JSON.parse(stdout);
expect(msg).toContain("multipart/mixed");
expect(msg).toContain("Content-Disposition: attachment");
expect(msg).toContain('filename="test.txt"');
// The attachment content should be base64-encoded
const b64 = Buffer.from("Hello from attachment").toString("base64");
expect(msg).toContain(b64);
expect(exitCode).toBe(0);
});
});
describe("Dot-stuffing (RFC 5321 §4.5.2)", () => {
test("should escape lines starting with a dot in message body", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com",
raw: "From: a@b.com\\r\\nTo: c@d.com\\r\\nSubject: dots\\r\\n\\r\\nLine 1\\r\\n.Line starting with dot\\r\\n..Two dots\\r\\nNormal line\\r\\n",
});
// The server should receive the message with dots UN-stuffed
// (the server strips the extra dot, so we see the original)
const msg = sessions[0].msg;
console.log(JSON.stringify(msg));
c.close(); server.stop();
`);
const msg: string = JSON.parse(stdout);
// Server should see the original lines (dot-stuffing is transparent)
expect(msg).toContain(".Line starting with dot");
expect(msg).toContain("..Two dots");
expect(exitCode).toBe(0);
});
test("should not prematurely end message with lone dot on a line", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com",
raw: "From: a@b.com\\r\\nTo: c@d.com\\r\\nSubject: dots\\r\\n\\r\\nBefore dot\\r\\n.\\r\\nAfter dot\\r\\n",
});
const msg = sessions[0].msg;
console.log(JSON.stringify({ hasAfterDot: msg.includes("After dot"), msgLen: msg.length }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
// The message should contain text AFTER the lone dot line
expect(d.hasAfterDot).toBe(true);
expect(d.msgLen).toBeGreaterThan(20);
expect(exitCode).toBe(0);
});
});
describe("Security: header injection prevention", () => {
test("should not allow newlines in subject to inject headers", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com",
subject: "Normal\\r\\nBcc: evil@hacker.com",
text: "test",
});
const msg = sessions[0].msg;
// The injected Bcc header should NOT appear as a separate header
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil@hacker.com"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
});
describe("Envelope handling", () => {
test("should use envelope override when provided", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({
from: "display@example.com", to: "display-to@example.com",
text: "test",
envelope: { from: "bounce@real.com", to: ["real-rcpt@real.com"] },
});
const cmds = sessions[0].cmds;
const mailFrom = cmds.find(c => c.startsWith("MAIL FROM:"));
const rcptTo = cmds.find(c => c.startsWith("RCPT TO:"));
console.log(JSON.stringify({
mailFrom, rcptTo,
accepted: r.accepted,
envFrom: r.envelope.from,
envTo: r.envelope.to,
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
// SMTP commands should use envelope addresses, not header addresses
expect(d.mailFrom).toMatch(/^MAIL FROM:<bounce@real\.com>/);
expect(d.rcptTo).toBe("RCPT TO:<real-rcpt@real.com>");
expect(d.accepted).toEqual(["real-rcpt@real.com"]);
expect(d.envFrom).toBe("bounce@real.com");
expect(d.envTo).toEqual(["real-rcpt@real.com"]);
expect(exitCode).toBe(0);
});
test("should include CC and BCC in RCPT TO but strip BCC from headers", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com",
to: "to@x.com",
cc: "cc@x.com",
bcc: "bcc@x.com",
text: "test",
});
const cmds = sessions[0].cmds;
const rcpts = cmds.filter(c => c.startsWith("RCPT TO:"));
const msg = sessions[0].msg;
console.log(JSON.stringify({
rcptCount: rcpts.length,
rcpts: rcpts.map(r => r.match(/<(.+?)>/)?.[1]),
hasBccHeader: msg.includes("Bcc:"),
hasCcHeader: msg.includes("Cc:"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.rcptCount).toBe(3);
expect(d.rcpts).toContain("to@x.com");
expect(d.rcpts).toContain("cc@x.com");
expect(d.rcpts).toContain("bcc@x.com");
// BCC should NOT appear in message headers
expect(d.hasBccHeader).toBe(false);
// CC should appear in message headers
expect(d.hasCcHeader).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Pool deep verification", () => {
test("should deliver each queued message with correct content to correct recipient", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, pool: true });
const [r1, r2, r3] = await Promise.all([
c.send({ from: "a@b.com", to: "first@x.com", subject: "S1", text: "Body 1" }),
c.send({ from: "a@b.com", to: "second@x.com", subject: "S2", text: "Body 2" }),
c.send({ from: "a@b.com", to: "third@x.com", subject: "S3", text: "Body 3" }),
]);
// Wait for all RSET responses (sequential sends on single connection)
// Verify each message went to correct recipient
console.log(JSON.stringify({
r1: r1.accepted, r2: r2.accepted, r3: r3.accepted,
// Check the RCPT TO commands in order
rcpts: sessions[0].cmds.filter(c => c.startsWith("RCPT TO:")).map(r => r.match(/<(.+?)>/)?.[1]),
// Check each message contains correct subject
msgs: sessions.flatMap(s => {
// In pool mode all messages go through one session
// But we capture per-DATA, so we need to collect messages from the mock
return [];
}),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.r1).toEqual(["first@x.com"]);
expect(d.r2).toEqual(["second@x.com"]);
expect(d.r3).toEqual(["third@x.com"]);
// All 3 recipients should appear in RCPT TO commands
expect(d.rcpts).toContain("first@x.com");
expect(d.rcpts).toContain("second@x.com");
expect(d.rcpts).toContain("third@x.com");
expect(exitCode).toBe(0);
});
});
describe("Error handling", () => {
test("should reject with EENVELOPE when sender is rejected", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ rejectSender: true });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log(JSON.stringify({ error: false }));
} catch(e) {
console.log(JSON.stringify({ error: true, code: e.code }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.error).toBe(true);
expect(d.code).toBe("EENVELOPE");
expect(exitCode).toBe(0);
});
test("should reject with EENVELOPE when ALL recipients rejected", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ rejectTo: ["a@x.com", "b@x.com"] });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "sender@x.com", to: ["a@x.com", "b@x.com"], text: "hi" });
console.log(JSON.stringify({ error: false }));
} catch(e) {
console.log(JSON.stringify({ error: true, code: e.code }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.error).toBe(true);
expect(d.code).toBe("EENVELOPE");
expect(exitCode).toBe(0);
});
test("should succeed with partial rejection", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ rejectTo: ["bad@x.com"] });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({ from: "a@b.com", to: ["good@x.com", "bad@x.com", "also-good@x.com"], text: "hi" });
console.log(JSON.stringify({ accepted: r.accepted, rejected: r.rejected }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.accepted).toEqual(["good@x.com", "also-good@x.com"]);
expect(d.rejected).toEqual(["bad@x.com"]);
expect(exitCode).toBe(0);
});
test("should reject with meaningful error when no 'from' provided", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ to: "c@d.com", text: "hi" });
console.log("no-error");
} catch(e) {
console.log(JSON.stringify({ msg: e.message }));
}
c.close(); server.stop();
`);
expect(JSON.parse(stdout).msg).toContain("from");
expect(exitCode).toBe(0);
});
test("should reject with meaningful error when no recipients provided", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
try {
await c.send({ from: "a@b.com", text: "hi" });
console.log("no-error");
} catch(e) {
console.log(JSON.stringify({ msg: e.message }));
}
c.close(); server.stop();
`);
expect(JSON.parse(stdout).msg).toContain("recipient");
expect(exitCode).toBe(0);
});
});
describe("Well-known services", () => {
test("should configure Gmail settings from service name", async () => {
const { stdout, exitCode } = await run(`
// Just test that the constructor accepts 'service' option without error
// and configures the right host (we can't actually connect to Gmail)
try {
const c = new Bun.SMTPClient({ service: "gmail", auth: { user: "test", pass: "test" } });
console.log(JSON.stringify({ created: true }));
c.close();
} catch(e) {
console.log(JSON.stringify({ error: e.message }));
}
`);
expect(JSON.parse(stdout).created).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Unicode support", () => {
test("should handle unicode in subject and body", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com",
subject: "Hello 你好 🌍",
text: "Unicode body: café, naïve, 日本語",
});
const msg = sessions[0].msg;
// Subject should be encoded (RFC 2047)
const hasEncodedSubject = msg.includes("=?UTF-8?") || msg.includes("Subject:");
// Body should contain the unicode text (possibly QP or base64 encoded)
const hasUnicodeBody = msg.includes("café") || msg.includes("caf=C3=A9") || msg.includes("Y2Fm");
console.log(JSON.stringify({ hasEncodedSubject, hasUnicodeBody }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasEncodedSubject).toBe(true);
expect(d.hasUnicodeBody).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("LMTP protocol", () => {
test("should use LHLO instead of EHLO in LMTP mode", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { const sess = { cmds: [], inData: false, buf: "" }; sessions.push(sess); s.data = sess; s.write("220 LMTP ready\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
s.data.cmds.push(l);
if (l.startsWith("LHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port, lmtp: true });
await c.send({ from: "a@b.com", to: "c@d.com", text: "lmtp test" });
const firstCmd = sessions[0].cmds[0];
console.log(JSON.stringify({ firstCmd, usesLHLO: firstCmd.startsWith("LHLO") }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.usesLHLO).toBe(true);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,362 +0,0 @@
/**
* Tests for code paths that had zero coverage.
* Found by auditing every branch in the source.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
const MOCK = `
function mock(opts = {}) {
let msgCount = 0;
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) {
const sess = { cmds: [], msg: "", inData: false, buf: "" };
sessions.push(sess); s.data = sess;
s.write("220 mock\\r\\n");
},
data(s, raw) {
const t = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) {
sess.buf += t;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false; msgCount++;
sess.msg = sess.buf.slice(0, sess.buf.indexOf("\\r\\n.\\r\\n"));
sess.buf = "";
s.write("250 OK\\r\\n");
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
sess.cmds.push(l);
if (l.startsWith("EHLO") || l.startsWith("LHLO")) {
let resp = "250-mock\\r\\n";
if (opts.auth) resp += "250-AUTH PLAIN LOGIN\\r\\n";
resp += "250 OK\\r\\n";
s.write(resp);
}
else if (l.startsWith("AUTH PLAIN")) s.write("235 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { sess.inData = true; sess.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
else s.write("235 OK\\r\\n");
}
},
},
});
return { server, sessions, port: server.port, getMsgCount: () => msgCount };
}
`;
describe("Multiple attachments", () => {
test("should include 3 attachments with separate boundaries", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "see attached",
attachments: [
{ filename: "file1.txt", content: "AAA" },
{ filename: "file2.txt", content: "BBB" },
{ filename: "file3.pdf", content: "CCC" },
],
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasFile1: msg.includes('filename="file1.txt"'),
hasFile2: msg.includes('filename="file2.txt"'),
hasFile3: msg.includes('filename="file3.pdf"'),
dispositionCount: (msg.match(/Content-Disposition: attachment/g) || []).length,
hasMixed: msg.includes("multipart/mixed"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasFile1).toBe(true);
expect(d.hasFile2).toBe(true);
expect(d.hasFile3).toBe(true);
expect(d.dispositionCount).toBe(3);
expect(d.hasMixed).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Inline images (CID)", () => {
test("should set Content-ID header for CID attachments", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com",
html: '<img src="cid:logo123"/>',
attachments: [{ filename: "logo.png", content: "PNGDATA", cid: "logo123" }],
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasCid: msg.includes("Content-ID: <logo123>") || msg.includes("Content-Id: <logo123>"),
hasInline: msg.includes("inline") || msg.includes("Content-ID"),
hasHtml: msg.includes("cid:logo123"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasCid).toBe(true);
expect(d.hasHtml).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Hex-encoded text body", () => {
test("should decode hex-encoded content in text body", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
// "Hello" = 48656c6c6f in hex
await c.send({
from: "a@b.com", to: "c@d.com",
text: { content: "48656c6c6f", encoding: "hex" },
});
const msg = sessions[0].msg;
console.log(JSON.stringify({ hasHello: msg.includes("Hello") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasHello).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Reconnect after close", () => {
test("should reconnect and send after close()", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port, getMsgCount } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "first" });
c.close();
// After close, send again - should reconnect
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: "second" });
console.log(JSON.stringify({
accepted: r.accepted,
total: getMsgCount(),
sessionCount: sessions.length,
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.accepted).toEqual(["c@d.com"]);
expect(d.total).toBe(2);
expect(d.sessionCount).toBe(2); // New TCP connection = new session
expect(exitCode).toBe(0);
});
});
describe("Concurrent sends without pool", () => {
test("should throw when sending concurrently without pool mode", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port }); // no pool: true
const p1 = c.send({ from: "a@b.com", to: "x@y.com", text: "first" });
let secondThrew = false;
try {
c.send({ from: "a@b.com", to: "x@y.com", text: "second" });
} catch(e) {
secondThrew = true;
}
await p1; // first should succeed
console.log(JSON.stringify({ secondThrew }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).secondThrew).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("inReplyTo and references headers", () => {
test("should include In-Reply-To and References headers", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "reply",
inReplyTo: "<original-id@example.com>",
references: "<ref1@example.com> <ref2@example.com>",
});
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasInReplyTo: msg.includes("In-Reply-To: <original-id@example.com>"),
hasReferences: msg.includes("References:") && msg.includes("<ref1@example.com>"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasInReplyTo).toBe(true);
expect(d.hasReferences).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("CC-only recipients (no to)", () => {
test("should send to CC recipients when no 'to' in envelope", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({
from: "a@b.com", cc: "cc-only@x.com", text: "cc only",
});
const rcpts = sessions[0].cmds.filter(c => c.startsWith("RCPT TO:"));
console.log(JSON.stringify({
accepted: r.accepted,
rcptCount: rcpts.length,
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.accepted).toEqual(["cc-only@x.com"]);
expect(d.rcptCount).toBe(1);
expect(exitCode).toBe(0);
});
});
describe("URL constructor", () => {
test("should parse smtp:// URL for connection config", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock({ auth: true });
// Use URL format with auth
const c = new Bun.SMTPClient("smtp://myuser:mypass@127.0.0.1:" + port);
await c.send({ from: "a@b.com", to: "c@d.com", text: "url test" });
const authCmd = sessions[0].cmds.find(c => c.startsWith("AUTH PLAIN"));
// Decode the AUTH PLAIN base64 to verify credentials
const decoded = authCmd ? Buffer.from(authCmd.slice(11), "base64").toString() : "";
console.log(JSON.stringify({
hasAuth: !!authCmd,
hasUser: decoded.includes("myuser"),
hasPass: decoded.includes("mypass"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasAuth).toBe(true);
expect(d.hasUser).toBe(true);
expect(d.hasPass).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("HTML-only email (no text)", () => {
test("should send HTML without text part", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", html: "<h1>HTML Only</h1>" });
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasHtml: msg.includes("<h1>HTML Only</h1>"),
hasContentType: msg.includes("text/html"),
noTextPlain: !msg.includes("text/plain"),
noMultipart: !msg.includes("multipart"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasHtml).toBe(true);
expect(d.hasContentType).toBe(true);
// HTML-only should not create multipart/alternative
expect(d.noMultipart).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Priority headers", () => {
test("should add priority headers for high priority", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "urgent", priority: "high" });
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasXPriority: msg.includes("X-Priority: 1"),
hasImportance: msg.includes("Importance: High") || msg.includes("Importance: high"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasXPriority).toBe(true);
expect(d.hasImportance).toBe(true);
expect(exitCode).toBe(0);
});
test("should add priority headers for low priority", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "not urgent", priority: "low" });
const msg = sessions[0].msg;
console.log(JSON.stringify({
hasXPriority: msg.includes("X-Priority: 5"),
hasImportance: msg.includes("Importance: Low") || msg.includes("Importance: low"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasXPriority).toBe(true);
expect(d.hasImportance).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("replyTo header", () => {
test("should include Reply-To header", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi", replyTo: "reply@example.com" });
const msg = sessions[0].msg;
console.log(JSON.stringify({ hasReplyTo: msg.includes("Reply-To: reply@example.com") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasReplyTo).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("keepBcc option", () => {
test("should keep BCC header in message when keepBcc is true", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, keepBcc: true });
await c.send({ from: "a@b.com", to: "c@d.com", bcc: "secret@x.com", text: "hi" });
const msg = sessions[0].msg;
console.log(JSON.stringify({ hasBcc: msg.includes("Bcc:") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasBcc).toBe(true);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,285 +0,0 @@
/**
* Direct port of vendor/nodemailer/test/mime-funcs/mime-funcs-test.js
* Tests internal MIME encoding functions via bun:internal-for-testing.
*/
import { describe, expect, test } from "bun:test";
// @ts-ignore
import { smtpInternals } from "bun:internal-for-testing";
const { isPlainText, hasLongerLines, encodeWord, encodeQP, foldHeader } = smtpInternals;
describe("#isPlainText (mime-funcs-test.js)", () => {
test("should detect plain text", () => {
expect(isPlainText("abc")).toBe(true);
expect(isPlainText("abc\x02")).toBe(false);
expect(isPlainText("abcõ")).toBe(false);
});
test("should return true for ASCII with special chars", () => {
expect(isPlainText("az09\t\r\n~!?")).toBe(true);
});
test("should return false on low bits", () => {
expect(isPlainText("az09\n\x08!?")).toBe(false);
});
test("should return false on high bits", () => {
expect(isPlainText("az09\nõ!?")).toBe(false);
});
});
describe("#hasLongerLines (mime-funcs-test.js)", () => {
test("should detect longer lines", () => {
expect(hasLongerLines("abc\ndef", 5)).toBe(false);
expect(hasLongerLines("juf\nabcdef\nghi", 5)).toBe(true);
});
});
describe("#encodeWord (mime-funcs-test.js)", () => {
test("should encode quoted-printable", () => {
expect(encodeWord("See on õhin test", "Q")).toBe("=?UTF-8?Q?See_on_=C3=B5hin_test?=");
});
test("should encode base64", () => {
expect(encodeWord("See on õhin test", "B")).toBe("=?UTF-8?B?U2VlIG9uIMO1aGluIHRlc3Q=?=");
});
});
describe("#encodeQP - direct (qp-test.js)", () => {
test("should encode UTF-8 string to QP", () => {
expect(encodeQP("abcd= ÕÄÖÜ")).toBe("abcd=3D =C3=95=C3=84=C3=96=C3=9C");
});
test("should encode trailing spaces", () => {
expect(encodeQP("foo bar ")).toBe("foo bar =20");
});
test("should encode trailing tabs", () => {
expect(encodeQP("foo bar\t\t")).toBe("foo bar\t=09");
});
test("should encode space before CRLF", () => {
expect(encodeQP("foo \r\nbar")).toBe("foo=20\r\nbar");
});
});
describe("#foldHeader (mime-funcs-test.js)", () => {
test("should not fold short header", () => {
expect(foldHeader("Subject: Hello World")).toBe("Subject: Hello World");
});
test("should fold long header at word boundary", () => {
const long = "Subject: " + "word ".repeat(20);
const folded = foldHeader(long);
// Should contain CRLF+space continuation
expect(folded).toContain("\r\n ");
// Each line should be <= 76 chars
for (const line of folded.split("\r\n")) {
expect(line.length).toBeLessThanOrEqual(78); // small slack for edge cases
}
// Content should be preserved
expect(folded.replace(/\r\n\s/g, " ")).toContain("word word word");
});
test("should fold encoded subject", () => {
const encoded = encodeWord(
"This is a very long subject with special characters like ÕÄÖÜ and more text to make it exceed the line limit",
"B",
);
const header = "Subject: " + encoded;
const folded = foldHeader(header);
// If the encoded word itself is >76 chars, folding may not break mid-word
// but the function should still return without error
expect(folded.length).toBeGreaterThan(0);
expect(folded).toContain("Subject:");
});
});
// ============================================================================
// Tests for per-attachment headers, encoded buffer content, ReDoS protection
// ============================================================================
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
const MOCK = `
function mock() {
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { const sess = { cmds: [], msg: "", inData: false, buf: "" }; sessions.push(sess); s.data = sess; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) { sess.buf += t; if (sess.buf.includes("\\r\\n.\\r\\n")) { sess.inData = false; sess.msg = sess.buf.split("\\r\\n.\\r\\n")[0]; sess.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250-mock\\r\\n250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { sess.inData = true; sess.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
return { server, sessions, port: server.port };
}
`;
describe("Per-attachment custom headers (mail-composer-test.js #24)", () => {
test("should include custom headers on attachments", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "abc",
attachments: [{
content: "test", filename: "test.txt",
headers: { "X-Test-1": "12345", "X-Test-2": "hello" },
}] });
const m = sessions[0].msg;
console.log(JSON.stringify({
hasX1: m.includes("X-Test-1: 12345"),
hasX2: m.includes("X-Test-2: hello"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasX1).toBe(true);
expect(d.hasX2).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Encoded buffer content (mail-composer-test.js #13)", () => {
test("text as { content, encoding: 'base64' } decodes correctly", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com",
text: { content: Buffer.from("tere tere").toString("base64"), encoding: "base64" } });
console.log(JSON.stringify({ has: sessions[0].msg.includes("tere tere") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).has).toBe(true);
expect(exitCode).toBe(0);
});
test("html as { content, encoding: 'base64' } decodes correctly", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com",
html: { content: Buffer.from("<b>decoded</b>").toString("base64"), encoding: "base64" } });
console.log(JSON.stringify({ has: sessions[0].msg.includes("<b>decoded</b>") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).has).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("ReDoS protection (mail-composer-test.js #35-46)", () => {
test("malicious data URL with 60000 semicolons completes fast", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const start = Date.now();
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
attachments: [{ filename: "t.txt", path: "data:;" + ";".repeat(60000) + ",test" }] });
console.log(JSON.stringify({ fast: Date.now() - start < 5000 }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).fast).toBe(true);
expect(exitCode).toBe(0);
});
test("valid data URL formats all work", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
attachments: [
{ filename: "t.txt", path: "data:text/plain,hello" },
{ filename: "t.html", path: "data:text/html,<b>hi</b>" },
] });
console.log(JSON.stringify({ ok: sessions[0].msg.length > 100 }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).ok).toBe(true);
expect(exitCode).toBe(0);
});
test("base64 data URL preserved", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const b64 = Buffer.from("Hello World").toString("base64");
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
attachments: [{ filename: "test.txt", path: "data:text/plain;base64," + b64 }] });
console.log(JSON.stringify({ has: sessions[0].msg.includes(b64) }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).has).toBe(true);
expect(exitCode).toBe(0);
});
test("200KB data URL completes without hang", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const start = Date.now();
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
attachments: [{ filename: "big.txt", path: "data:text/plain;base64," + "A".repeat(200000) }] });
console.log(JSON.stringify({ fast: Date.now() - start < 10000 }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).fast).toBe(true);
expect(exitCode).toBe(0);
});
test("malformed data URLs don't crash", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
attachments: [{ filename: "e.txt", path: "data:," }] });
console.log(JSON.stringify({ ok: sessions[0].msg.includes("text/plain") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).ok).toBe(true);
expect(exitCode).toBe(0);
});
test("invalid base64 in data URL doesn't crash", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
attachments: [{ filename: "bad.txt", path: "data:text/plain;base64,!!!invalid!!!" }] });
console.log(JSON.stringify({ ok: sessions[0].msg.length > 50 }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).ok).toBe(true);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,470 +0,0 @@
/**
* Tests for newly implemented SMTP features:
* - Pool mode (queue concurrent sends)
* - Proxy support (HTTP CONNECT)
* - Sendmail transport
* - createTestAccount
* - Comma-separated address parsing
* - Result object format (arrays)
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
const MOCK = `
function mock(opts = {}) {
let msgCount = 0;
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) {
const sess = { cmds: [], msg: "", inData: false, buf: "" };
sessions.push(sess); s.data = sess;
s.write("220 mock SMTP\\r\\n");
},
data(s, raw) {
const t = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) {
sess.buf += t;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false; msgCount++;
sess.msg = sess.buf.split("\\r\\n.\\r\\n")[0]; sess.buf = "";
s.write("250 OK msg #" + msgCount + "\\r\\n");
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
sess.cmds.push(l);
if (l.startsWith("EHLO")) s.write("250-mock\\r\\n250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) {
const addr = l.match(/<(.+?)>/)?.[1];
if (opts.rejectTo && opts.rejectTo === addr) s.write("550 rejected\\r\\n");
else s.write("250 OK\\r\\n");
}
else if (l === "DATA") { sess.inData = true; sess.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
return { server, sessions, port: server.port, getMsgCount: () => msgCount };
}
`;
describe("Comma-separated address parsing", () => {
test("should split comma-separated to string into multiple recipients", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({ from: "a@b.com", to: "first@a.com, second@b.com, third@c.com", text: "hi" });
const rcpts = sessions[0].cmds.filter(c => c.startsWith("RCPT TO:"));
console.log(JSON.stringify({
accepted: r.accepted,
rcptCount: rcpts.length,
has1: rcpts.some(r => r.includes("first@a.com")),
has2: rcpts.some(r => r.includes("second@b.com")),
has3: rcpts.some(r => r.includes("third@c.com")),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.accepted).toEqual(["first@a.com", "second@b.com", "third@c.com"]);
expect(d.rcptCount).toBe(3);
expect(d.has1).toBe(true);
expect(d.has2).toBe(true);
expect(d.has3).toBe(true);
expect(exitCode).toBe(0);
});
test("should handle display names in comma-separated string", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({ from: "a@b.com", to: '"Alice" <alice@x.com>, Bob <bob@y.com>', text: "hi" });
console.log(JSON.stringify({ accepted: r.accepted }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).accepted).toEqual(["alice@x.com", "bob@y.com"]);
expect(exitCode).toBe(0);
});
test("should split comma-separated addresses within array elements", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({ from: "a@b.com", to: ["x@a.com, y@b.com", "z@c.com"], text: "hi" });
console.log(JSON.stringify({ accepted: r.accepted }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).accepted).toEqual(["x@a.com", "y@b.com", "z@c.com"]);
expect(exitCode).toBe(0);
});
});
describe("Result object format (nodemailer compatible)", () => {
test("should return accepted/rejected as arrays of addresses", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port } = mock({ rejectTo: "bad@x.com" });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const r = await c.send({ from: "a@b.com", to: ["good@x.com", "bad@x.com"], text: "hi" });
console.log(JSON.stringify({
accepted: r.accepted,
rejected: r.rejected,
hasEnvelope: typeof r.envelope === "object",
envFrom: r.envelope?.from,
envTo: r.envelope?.to,
hasMessageId: typeof r.messageId === "string" && r.messageId.startsWith("<"),
hasResponse: typeof r.response === "string",
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.accepted).toEqual(["good@x.com"]);
expect(d.rejected).toEqual(["bad@x.com"]);
expect(d.hasEnvelope).toBe(true);
expect(d.envFrom).toBe("a@b.com");
expect(d.envTo).toEqual(["good@x.com", "bad@x.com"]);
expect(d.hasMessageId).toBe(true);
expect(d.hasResponse).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Pool mode (pool: true)", () => {
test("should queue concurrent sends and deliver all", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, port, getMsgCount } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, pool: true });
const [r1, r2, r3] = await Promise.all([
c.send({ from: "a@b.com", to: "x@y.com", text: "msg 1" }),
c.send({ from: "a@b.com", to: "p@q.com", text: "msg 2" }),
c.send({ from: "a@b.com", to: "s@t.com", text: "msg 3" }),
]);
console.log(JSON.stringify({
r1: r1.accepted, r2: r2.accepted, r3: r3.accepted,
total: getMsgCount(),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.r1).toEqual(["x@y.com"]);
expect(d.r2).toEqual(["p@q.com"]);
expect(d.r3).toEqual(["s@t.com"]);
expect(d.total).toBe(3);
expect(exitCode).toBe(0);
});
test("should rotate connections after maxMessages", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
let ehloCount = 0;
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "" }; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) {
s.data.buf += t;
if (s.data.buf.includes("\\r\\n.\\r\\n")) {
s.data.inData = false; s.data.buf = "";
s.write("250 OK\\r\\n");
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) { ehloCount++; s.write("250 OK\\r\\n"); }
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port, pool: true, maxMessages: 2 });
await c.send({ from: "a@b.com", to: "x@y.com", text: "1" });
await c.send({ from: "a@b.com", to: "x@y.com", text: "2" });
// Third send should trigger reconnect (maxMessages=2 reached)
await c.send({ from: "a@b.com", to: "x@y.com", text: "3" });
console.log(JSON.stringify({ ehloCount }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
// 2 EHLO calls: first connection + reconnect after maxMessages
expect(d.ehloCount).toBe(2);
expect(exitCode).toBe(0);
});
});
describe("Proxy support", () => {
test("should send HTTP CONNECT to proxy", async () => {
const { stdout, exitCode } = await run(`
let connectLine = "";
const proxy = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = ""; },
data(s, raw) {
s.data += new TextDecoder().decode(raw);
if (s.data.includes("\\r\\n\\r\\n")) {
connectLine = s.data.split("\\r\\n")[0];
// Don't respond - let it timeout or just close
s.end();
}
},
},
});
const c = new Bun.SMTPClient({
host: "smtp.example.com", port: 587,
proxy: "http://127.0.0.1:" + proxy.port,
});
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
} catch(e) {
// Expected to fail since proxy doesn't actually tunnel
}
console.log(JSON.stringify({ connectLine }));
c.close(); proxy.stop();
`);
const d = JSON.parse(stdout);
expect(d.connectLine).toMatch(/^CONNECT smtp\.example\.com:587 HTTP\/1\.1/);
expect(exitCode).toBe(0);
});
test("should include Proxy-Authorization with credentials", async () => {
const { stdout, exitCode } = await run(`
let headers = "";
const proxy = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = ""; },
data(s, raw) {
s.data += new TextDecoder().decode(raw);
if (s.data.includes("\\r\\n\\r\\n")) {
headers = s.data;
s.end();
}
},
},
});
const c = new Bun.SMTPClient({
host: "smtp.example.com", port: 587,
proxy: "http://user:pass@127.0.0.1:" + proxy.port,
});
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
} catch(e) {}
const hasAuth = headers.includes("Proxy-Authorization: Basic " + Buffer.from("user:pass").toString("base64"));
console.log(JSON.stringify({ hasAuth }));
c.close(); proxy.stop();
`);
expect(JSON.parse(stdout).hasAuth).toBe(true);
expect(exitCode).toBe(0);
});
});
describe.if(process.platform !== "win32")("Sendmail transport", () => {
test("should send via sendmail binary", async () => {
const { stdout, exitCode } = await run(`
const c = new Bun.SMTPClient({ sendmail: "/bin/true" });
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log(JSON.stringify({
hasAccepted: Array.isArray(r.accepted),
hasResponse: typeof r.response === "string",
}));
c.close();
`);
const d = JSON.parse(stdout);
expect(d.hasAccepted).toBe(true);
expect(d.hasResponse).toBe(true);
expect(exitCode).toBe(0);
});
test("should reject on sendmail failure", async () => {
const { stdout, exitCode } = await run(`
const c = new Bun.SMTPClient({ sendmail: "/bin/false" });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
console.log("ERROR: should have thrown");
} catch(e) {
console.log(JSON.stringify({ error: e.message.substring(0, 30) }));
}
c.close();
`);
expect(JSON.parse(stdout).error).toContain("sendmail exited");
expect(exitCode).toBe(0);
});
test("sendmail: true should default to /usr/sbin/sendmail path", async () => {
const { stdout, exitCode } = await run(`
const c = new Bun.SMTPClient({ sendmail: true });
// Just verify construction works - don't actually try to send
// since /usr/sbin/sendmail may not exist
console.log(JSON.stringify({ created: true }));
c.close();
`);
expect(JSON.parse(stdout).created).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("createTestAccount", () => {
test("should be a static method", async () => {
const { stdout, exitCode } = await run(`
console.log(JSON.stringify({
type: typeof Bun.SMTPClient.createTestAccount,
}));
`);
expect(JSON.parse(stdout).type).toBe("function");
expect(exitCode).toBe(0);
});
});
describe("Connection reuse", () => {
test("should reuse connection for sequential sends (1 EHLO)", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
let ehloCount = 0;
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "" }; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) {
s.data.buf += t;
if (s.data.buf.includes("\\r\\n.\\r\\n")) {
s.data.inData = false; s.data.buf = "";
s.write("250 OK\\r\\n");
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) { ehloCount++; s.write("250 OK\\r\\n"); }
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({ from: "a@b.com", to: "x@y.com", text: "1" });
await c.send({ from: "a@b.com", to: "x@y.com", text: "2" });
await c.send({ from: "a@b.com", to: "x@y.com", text: "3" });
console.log(JSON.stringify({ ehloCount }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.ehloCount).toBe(1);
expect(exitCode).toBe(0);
});
});
describe("REQUIRETLS extension (RFC 8689)", () => {
test("should add REQUIRETLS parameter to MAIL FROM when server supports it", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { const sess = { cmds: [], inData: false, buf: "" }; sessions.push(sess); s.data = sess; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
s.data.cmds.push(l);
if (l.startsWith("EHLO")) s.write("250-mock\\r\\n250-REQUIRETLS\\r\\n250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port, requireTLSExtension: true });
await c.send({ from: "a@b.com", to: "c@d.com", text: "secure" });
const mailFrom = sessions[0].cmds.find(c => c.startsWith("MAIL FROM:"));
console.log(JSON.stringify({ mailFrom, hasRequireTLS: mailFrom?.includes("REQUIRETLS") }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasRequireTLS).toBe(true);
expect(exitCode).toBe(0);
});
test("should error when server does not support REQUIRETLS but option is set", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port, requireTLSExtension: true });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "test" });
console.log(JSON.stringify({ error: false }));
} catch(e) {
console.log(JSON.stringify({ error: true, code: e.code, msg: e.message.substring(0, 50) }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.error).toBe(true);
expect(d.code).toBe("EENVELOPE");
expect(exitCode).toBe(0);
});
test("should NOT add REQUIRETLS when not requested", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { const sess = { cmds: [], inData: false, buf: "" }; sessions.push(sess); s.data = sess; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
s.data.cmds.push(l);
if (l.startsWith("EHLO")) s.write("250-mock\\r\\n250-REQUIRETLS\\r\\n250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({ from: "a@b.com", to: "c@d.com", text: "normal" });
const mailFrom = sessions[0].cmds.find(c => c.startsWith("MAIL FROM:"));
console.log(JSON.stringify({ noRequireTLS: !mailFrom?.includes("REQUIRETLS") }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).noRequireTLS).toBe(true);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,576 +0,0 @@
/**
* Tests ported directly from nodemailer test suite.
* These verify byte-level compatibility with nodemailer's output.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function runSmtp(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
const MOCK_SERVER = `
function createMockSMTP(opts = {}) {
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(socket) {
const sess = { commands: [], message: "", inData: false, buf: "" };
sessions.push(sess);
socket.data = sess;
socket.write("220 localhost ESMTP\\r\\n");
},
data(socket, raw) {
const text = new TextDecoder().decode(raw);
const sess = socket.data;
if (sess.inData) {
sess.buf += text;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false;
sess.message = sess.buf.split("\\r\\n.\\r\\n")[0];
sess.buf = "";
socket.write("250 OK\\r\\n");
}
return;
}
for (const line of text.split("\\r\\n").filter(l => l)) {
sess.commands.push(line);
if (line.startsWith("EHLO") || line.startsWith("HELO")) {
socket.write("250-localhost\\r\\n250-AUTH PLAIN LOGIN\\r\\n250 OK\\r\\n");
} else if (line.startsWith("MAIL FROM:")) socket.write("250 OK\\r\\n");
else if (line.startsWith("RCPT TO:")) socket.write("250 OK\\r\\n");
else if (line === "DATA") { sess.inData = true; sess.buf = ""; socket.write("354 Go\\r\\n"); }
else if (line.startsWith("RSET")) socket.write("250 OK\\r\\n");
else if (line === "QUIT") { socket.write("221 Bye\\r\\n"); socket.end(); }
}
},
},
});
return { server, sessions, port: server.port };
}
`;
describe("Nodemailer Compatibility - QP Encoding (from qp-test.js)", () => {
// Direct port of nodemailer's QP encode fixtures
const qpFixtures = [
["abcd= ÕÄÖÜ", "abcd=3D =C3=95=C3=84=C3=96=C3=9C"],
["foo bar ", "foo bar =20"],
["foo bar\t\t", "foo bar\t=09"],
["foo \r\nbar", "foo=20\r\nbar"],
];
for (const [input, expected] of qpFixtures) {
test(`QP encode: ${JSON.stringify(input).slice(0, 40)}`, async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
text: ${JSON.stringify(input)},
});
// Extract the body after headers
const msg = sessions[0].message;
const parts = msg.split("\\r\\n\\r\\n");
const body = parts.slice(1).join("\\r\\n\\r\\n");
console.log(JSON.stringify({ body }));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.body).toBe(expected);
expect(exitCode).toBe(0);
});
}
});
describe("Nodemailer Compatibility - BCC Handling (from mail-composer-test.js)", () => {
test("should NOT include BCC in message headers", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "sender@test.com",
to: "visible@test.com",
bcc: "hidden@test.com",
text: "secret BCC test",
});
const msg = sessions[0].message;
const cmds = sessions[0].commands;
const rcpts = cmds.filter(c => c.startsWith("RCPT TO:"));
console.log(JSON.stringify({
bccInHeaders: msg.includes("Bcc:") || msg.includes("bcc:"),
bccInEnvelope: rcpts.some(r => r.includes("hidden@test.com")),
visibleInEnvelope: rcpts.some(r => r.includes("visible@test.com")),
rcptCount: rcpts.length,
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
// BCC must NOT appear in headers (privacy requirement per RFC 5321)
expect(data.bccInHeaders).toBe(false);
// But BCC must be in the envelope
expect(data.bccInEnvelope).toBe(true);
expect(data.visibleInEnvelope).toBe(true);
expect(data.rcptCount).toBe(2);
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - Date Header", () => {
test("should use current date, not hardcoded", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({ from: "a@b.com", to: "c@d.com", text: "date test" });
const msg = sessions[0].message;
const dateLine = msg.split("\\r\\n").find(l => l.startsWith("Date:"));
const year = new Date().getUTCFullYear();
console.log(JSON.stringify({
dateLine,
hasCurrentYear: dateLine.includes(String(year)),
notHardcoded: !dateLine.includes("Thu, 01 Jan 2026"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.hasCurrentYear).toBe(true);
expect(data.notHardcoded).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - Message Structure (from mail-composer-test.js)", () => {
test("text only: should produce single text/plain part", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({ from: "a@b.com", to: "c@d.com", text: "abc" });
const msg = sessions[0].message;
console.log(JSON.stringify({
hasTextPlain: msg.includes("Content-Type: text/plain; charset=utf-8"),
noMultipart: !msg.includes("multipart"),
hasBody: msg.includes("abc"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data).toEqual({ hasTextPlain: true, noMultipart: true, hasBody: true });
expect(exitCode).toBe(0);
});
test("html only: should produce single text/html part", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({ from: "a@b.com", to: "c@d.com", html: "<p>def</p>" });
const msg = sessions[0].message;
console.log(JSON.stringify({
hasTextHtml: msg.includes("Content-Type: text/html; charset=utf-8"),
noMultipart: !msg.includes("multipart"),
hasBody: msg.includes("<p>def</p>"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data).toEqual({ hasTextHtml: true, noMultipart: true, hasBody: true });
expect(exitCode).toBe(0);
});
test("text + html: should produce multipart/alternative with both parts", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({ from: "a@b.com", to: "c@d.com", text: "abc", html: "<p>def</p>" });
const msg = sessions[0].message;
// Text should come before HTML in multipart/alternative
const textPos = msg.indexOf("text/plain");
const htmlPos = msg.indexOf("text/html");
console.log(JSON.stringify({
hasAlternative: msg.includes("multipart/alternative"),
textBeforeHtml: textPos < htmlPos && textPos > 0,
hasText: msg.includes("abc"),
hasHtml: msg.includes("<p>def</p>"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data).toEqual({
hasAlternative: true,
textBeforeHtml: true,
hasText: true,
hasHtml: true,
});
expect(exitCode).toBe(0);
});
test("text + attachment: should produce multipart/mixed", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
text: "abc",
attachments: [{ content: "def", filename: "test.txt" }],
});
const msg = sessions[0].message;
console.log(JSON.stringify({
hasMixed: msg.includes("multipart/mixed"),
hasText: msg.includes("abc"),
hasAttachment: msg.includes("def"),
hasBase64: msg.includes("Content-Transfer-Encoding: base64"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.hasMixed).toBe(true);
expect(data.hasText).toBe(true);
expect(data.hasBase64).toBe(true);
expect(exitCode).toBe(0);
});
test("text + html + attachment: should produce mixed with nested alternative", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
text: "plain",
html: "<b>rich</b>",
attachments: [{ content: "file", filename: "att.txt" }],
});
const msg = sessions[0].message;
const mixedPos = msg.indexOf("multipart/mixed");
const altPos = msg.indexOf("multipart/alternative");
console.log(JSON.stringify({
hasMixed: mixedPos >= 0,
hasAlternative: altPos >= 0,
mixedOutermost: mixedPos < altPos,
hasText: msg.includes("plain"),
hasHtml: msg.includes("<b>rich</b>"),
hasAttachment: msg.includes('filename="att.txt"'),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data).toEqual({
hasMixed: true,
hasAlternative: true,
mixedOutermost: true,
hasText: true,
hasHtml: true,
hasAttachment: true,
});
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - CID Inline Attachment (from mail-composer-test.js)", () => {
test("should create multipart/related-like structure with CID attachment", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
html: '<img src="cid:image001"/>',
attachments: [
{ content: "fakeimgdata", filename: "image.png", cid: "image001" },
],
});
const msg = sessions[0].message;
console.log(JSON.stringify({
hasCid: msg.includes("Content-Id: <image001>"),
hasInline: msg.includes("Content-Disposition: inline"),
hasPng: msg.includes("image/png"),
hasHtml: msg.includes("<img"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.hasCid).toBe(true);
expect(data.hasInline).toBe(true);
expect(data.hasPng).toBe(true);
expect(data.hasHtml).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - Address Handling (from addressparser-test.js)", () => {
test("should extract email from display name format", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: '"Andris Reinman" <andris@tr.ee>',
to: '"Recipient" <to@example.com>',
text: "test",
});
const cmds = sessions[0].commands;
const msg = sessions[0].message;
console.log(JSON.stringify({
envelopeFrom: cmds.find(c => c.startsWith("MAIL FROM:")),
envelopeTo: cmds.find(c => c.startsWith("RCPT TO:")),
headerFrom: msg.split("\\r\\n").find(l => l.startsWith("From:")),
headerTo: msg.split("\\r\\n").find(l => l.startsWith("To:")),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
// Envelope should have bare email
expect(data.envelopeFrom).toContain("<andris@tr.ee>");
expect(data.envelopeTo).toBe("RCPT TO:<to@example.com>");
// Headers should preserve display name
expect(data.headerFrom).toBe('From: "Andris Reinman" <andris@tr.ee>');
expect(data.headerTo).toBe('To: "Recipient" <to@example.com>');
expect(exitCode).toBe(0);
});
test("should handle bare email without angle brackets", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({ from: "plain@email.com", to: "dest@email.com", text: "test" });
const cmds = sessions[0].commands;
console.log(JSON.stringify({
from: cmds.find(c => c.startsWith("MAIL FROM:")),
to: cmds.find(c => c.startsWith("RCPT TO:")),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.from).toContain("<plain@email.com>");
expect(data.to).toBe("RCPT TO:<dest@email.com>");
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - RFC 2047 Subject Encoding (from mime-funcs-test.js)", () => {
test("should encode non-ASCII subject as =?UTF-8?B?...?=", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
subject: "See on õhin test",
text: "body",
});
const msg = sessions[0].message;
const subjectLine = msg.split("\\r\\n").find(l => l.startsWith("Subject:"));
// Decode and check roundtrip
const match = subjectLine.match(/=\\?UTF-8\\?B\\?(.+?)\\?=/);
const decoded = match ? Buffer.from(match[1], "base64").toString("utf-8") : "";
console.log(JSON.stringify({ encoded: !!match, decoded }));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.encoded).toBe(true);
expect(data.decoded).toBe("See on õhin test");
expect(exitCode).toBe(0);
});
test("should NOT encode pure ASCII subject", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
subject: "Hello World",
text: "body",
});
const msg = sessions[0].message;
const subjectLine = msg.split("\\r\\n").find(l => l.startsWith("Subject:"));
console.log(JSON.stringify({ subject: subjectLine }));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.subject).toBe("Subject: Hello World");
expect(exitCode).toBe(0);
});
test("should encode emoji subject correctly", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
await client.send({
from: "a@b.com", to: "c@d.com",
subject: "Hello 🌍 World 💮",
text: "body",
});
const msg = sessions[0].message;
const subjectLine = msg.split("\\r\\n").find(l => l.startsWith("Subject:"));
const match = subjectLine.match(/=\\?UTF-8\\?B\\?(.+?)\\?=/);
const decoded = match ? Buffer.from(match[1], "base64").toString("utf-8") : "";
console.log(JSON.stringify({ decoded }));
client.close(); server.stop();
`);
expect(JSON.parse(stdout.trim()).decoded).toBe("Hello 🌍 World 💮");
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - Raw Message (from mail-composer-test.js)", () => {
test("raw message should be sent as-is without modification", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
const raw = "From: raw@test.com\\r\\nTo: dest@test.com\\r\\nSubject: Raw Test\\r\\nX-Custom: rawval\\r\\n\\r\\nRaw body content";
await client.send({ from: "raw@test.com", to: "dest@test.com", raw });
const msg = sessions[0].message;
console.log(JSON.stringify({
exactMatch: msg === raw,
noExtraHeaders: !msg.includes("MIME-Version") && !msg.includes("X-Mailer"),
hasRawSubject: msg.includes("Subject: Raw Test"),
hasRawBody: msg.includes("Raw body content"),
hasRawCustom: msg.includes("X-Custom: rawval"),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.exactMatch).toBe(true);
expect(data.noExtraHeaders).toBe(true);
expect(data.hasRawSubject).toBe(true);
expect(data.hasRawBody).toBe(true);
expect(data.hasRawCustom).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - Well-Known Services (from well-known-test.js)", () => {
test("should resolve Gmail", async () => {
const { stdout, exitCode } = await runSmtp(`
const client = new Bun.SMTPClient({ service: "gmail", auth: { user: "x", pass: "y" } });
console.log(JSON.stringify({ secure: client.secure }));
client.close();
`);
expect(JSON.parse(stdout.trim()).secure).toBe(true);
expect(exitCode).toBe(0);
});
test("should resolve 'Google Mail' (case insensitive)", async () => {
const { stdout, exitCode } = await runSmtp(`
const client = new Bun.SMTPClient({ service: "GoogleMail" });
console.log(JSON.stringify({ secure: client.secure }));
client.close();
`);
expect(JSON.parse(stdout.trim()).secure).toBe(true);
expect(exitCode).toBe(0);
});
test("should resolve Outlook365", async () => {
const { stdout, exitCode } = await runSmtp(`
const client = new Bun.SMTPClient({ service: "Outlook365" });
// Outlook365 uses port 587 with STARTTLS, not direct TLS
console.log(JSON.stringify({ secure: client.secure }));
client.close();
`);
expect(JSON.parse(stdout.trim()).secure).toBe(false);
expect(exitCode).toBe(0);
});
test("should resolve by email domain (gmail.com)", async () => {
const { stdout, exitCode } = await runSmtp(`
const client = new Bun.SMTPClient({ service: "gmail.com" });
console.log(JSON.stringify({ secure: client.secure }));
client.close();
`);
expect(JSON.parse(stdout.trim()).secure).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - verify() (from smtp-transport-test.js)", () => {
test("should verify connection without auth", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
const { server, sessions, port } = createMockSMTP();
const client = new Bun.SMTPClient({ host: "127.0.0.1", port });
const result = await client.verify();
console.log(JSON.stringify({
verified: result !== undefined && result !== null,
hasEhlo: sessions[0].commands.some(c => c.startsWith("EHLO")),
}));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.verified).toBe(true);
expect(data.hasEhlo).toBe(true);
expect(exitCode).toBe(0);
});
test("should reject verify on connection failure", async () => {
const { stdout, exitCode } = await runSmtp(`
const client = new Bun.SMTPClient({ host: "127.0.0.1", port: 62542 });
try { await client.verify(); console.log("ERROR: should have thrown"); }
catch(e) { console.log("rejected:", e.message.substring(0, 40)); }
client.close();
`);
expect(stdout).toContain("rejected:");
expect(exitCode).toBe(0);
});
});
describe("Nodemailer Compatibility - Connection Reuse (from smtp-pool-test.js)", () => {
test("should send 5 messages over a single connection", async () => {
const { stdout, exitCode } = await runSmtp(`
${MOCK_SERVER}
let ehloCount = 0;
let msgCount = 0;
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(socket) { socket.data = { inData: false, buf: "" }; socket.write("220 OK\\r\\n"); },
data(socket, raw) {
const text = new TextDecoder().decode(raw);
if (socket.data.inData) {
socket.data.buf += text;
if (socket.data.buf.includes("\\r\\n.\\r\\n")) {
socket.data.inData = false; msgCount++;
socket.data.buf = "";
socket.write("250 OK #" + msgCount + "\\r\\n");
}
return;
}
for (const line of text.split("\\r\\n").filter(l => l)) {
if (line.startsWith("EHLO")) { ehloCount++; socket.write("250 OK\\r\\n"); }
else if (line.startsWith("MAIL")) socket.write("250 OK\\r\\n");
else if (line.startsWith("RCPT")) socket.write("250 OK\\r\\n");
else if (line === "DATA") { socket.data.inData = true; socket.data.buf = ""; socket.write("354 Go\\r\\n"); }
else if (line.startsWith("RSET")) socket.write("250 OK\\r\\n");
else if (line === "QUIT") { socket.write("221 Bye\\r\\n"); socket.end(); }
}
},
},
});
const client = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
for (let i = 0; i < 5; i++) {
await client.send({ from: "a@b.com", to: "c@d.com", text: "Message " + (i+1) });
}
console.log(JSON.stringify({ msgCount, ehloCount }));
client.close(); server.stop();
`);
const data = JSON.parse(stdout.trim());
expect(data.msgCount).toBe(5);
expect(data.ehloCount).toBe(1); // Only one EHLO = one connection
expect(exitCode).toBe(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,439 +0,0 @@
/**
* Production readiness tests. These test edge cases that would break
* in real-world usage but pass with toy mock servers.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
describe("Chunked server responses", () => {
test("should handle EHLO response split across multiple TCP segments", async () => {
const { stdout, exitCode } = await run(`
// Server that sends EHLO response one byte at a time
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) {
sessions.push({ cmds: [], msg: "", inData: false, buf: "" });
s.data = sessions[sessions.length - 1];
// Send greeting in two chunks
s.write("220 ");
queueMicrotask(() => s.write("mock ready\\r\\n"));
},
data(s, raw) {
const t = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) {
sess.buf += t;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false;
sess.msg = sess.buf.slice(0, sess.buf.indexOf("\\r\\n.\\r\\n"));
sess.buf = "";
s.write("250 OK\\r\\n");
}
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
sess.cmds.push(l);
if (l.startsWith("EHLO")) {
// Send multiline EHLO response in separate writes
s.write("250-mock\\r\\n");
queueMicrotask(() => {
s.write("250-AUTH PLAIN\\r\\n");
queueMicrotask(() => s.write("250 SIZE 1000000\\r\\n"));
});
}
else if (l.startsWith("AUTH")) s.write("235 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { sess.inData = true; sess.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port, auth: { user: "u", pass: "p" } });
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: "chunked test" });
console.log(JSON.stringify({ accepted: r.accepted }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).accepted).toEqual(["c@d.com"]);
expect(exitCode).toBe(0);
});
});
describe("Null bytes and malicious input", () => {
test("should handle null bytes in subject without crashing", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "", msg: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.msg = s.data.buf.slice(0, s.data.buf.indexOf("\\r\\n.\\r\\n")); s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
const r = await c.send({
from: "a@b.com", to: "c@d.com",
subject: "Before\\x00After",
text: "Body with \\x00 null",
});
console.log(JSON.stringify({ sent: r.accepted.length === 1 }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).sent).toBe(true);
expect(exitCode).toBe(0);
});
test("should handle very long email address without crashing", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
const longLocal = Buffer.alloc(200, "a").toString();
const r = await c.send({ from: "a@b.com", to: longLocal + "@example.com", text: "long addr" });
console.log(JSON.stringify({ sent: r.accepted.length === 1 }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).sent).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Auth credential encoding", () => {
test("should encode passwords with special chars in AUTH PLAIN", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { cmds: [], inData: false, buf: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
s.data.cmds.push(l);
if (l.startsWith("EHLO")) s.write("250-mock\\r\\n250-AUTH PLAIN\\r\\n250 OK\\r\\n");
else if (l.startsWith("AUTH PLAIN")) s.write("235 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({
host: "127.0.0.1", port: server.port,
auth: { user: "user@domain.com", pass: "p@ss=w0rd!" },
});
await c.send({ from: "a@b.com", to: "c@d.com", text: "test" });
// Verify the AUTH PLAIN credential was properly base64 encoded
const authCmd = sessions[0].cmds.find(c => c.startsWith("AUTH PLAIN "));
const decoded = Buffer.from(authCmd.slice(11), "base64").toString("binary");
// AUTH PLAIN format: \\0user\\0pass
const parts = decoded.split("\\0");
console.log(JSON.stringify({
user: parts[1],
pass: parts[2],
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.user).toBe("user@domain.com");
expect(d.pass).toBe("p@ss=w0rd!");
expect(exitCode).toBe(0);
});
});
describe("Many recipients", () => {
test("should handle 20 recipients with correct accepted/rejected tracking", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const rejectList = ["r3@x.com", "r7@x.com", "r15@x.com"];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT TO:")) {
const addr = l.match(/<(.+?)>/)?.[1];
if (rejectList.includes(addr)) s.write("550 Rejected\\r\\n");
else s.write("250 OK\\r\\n");
}
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
const recipients = [];
for (let i = 0; i < 20; i++) recipients.push("r" + i + "@x.com");
const r = await c.send({ from: "a@b.com", to: recipients, text: "many" });
console.log(JSON.stringify({
acceptedCount: r.accepted.length,
rejectedCount: r.rejected.length,
rejected: r.rejected.sort(),
total: r.accepted.length + r.rejected.length,
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.acceptedCount).toBe(17);
expect(d.rejectedCount).toBe(3);
expect(d.rejected).toEqual(["r15@x.com", "r3@x.com", "r7@x.com"]);
expect(d.total).toBe(20);
expect(exitCode).toBe(0);
});
});
describe("Empty/edge-case fields", () => {
test("should send email with empty subject", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "", msg: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.msg = s.data.buf.slice(0, s.data.buf.indexOf("\\r\\n.\\r\\n")); s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
const r = await c.send({ from: "a@b.com", to: "c@d.com", subject: "", text: "no subject" });
console.log(JSON.stringify({ sent: r.accepted.length === 1, hasBody: sessions[0].msg.includes("no subject") }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.sent).toBe(true);
expect(d.hasBody).toBe(true);
expect(exitCode).toBe(0);
});
test("should send email with no subject at all", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "", msg: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.msg = s.data.buf.slice(0, s.data.buf.indexOf("\\r\\n.\\r\\n")); s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
// No subject field at all
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: "body only" });
const msg = sessions[0].msg;
console.log(JSON.stringify({
sent: r.accepted.length === 1,
noSubject: !msg.includes("Subject:"),
hasBody: msg.includes("body only"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.sent).toBe(true);
expect(d.hasBody).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Connection drop during DATA", () => {
test("should reject promise when server disconnects during message body", async () => {
const { stdout, exitCode } = await run(`
let dataReceived = false;
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false }; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) {
// Server crashes after receiving some data
dataReceived = true;
s.end(); // Abrupt disconnect!
return;
}
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.write("354 Go\\r\\n"); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port, connectionTimeout: 3000 });
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "will be interrupted" });
console.log(JSON.stringify({ error: false }));
} catch(e) {
console.log(JSON.stringify({ error: true, code: e.code }));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.error).toBe(true);
expect(d.code).toBe("ECONNECTION");
expect(exitCode).toBe(0);
});
});
describe("Binary attachment data", () => {
test("should handle binary Uint8Array attachment correctly", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "", msg: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.msg = s.data.buf.slice(0, s.data.buf.indexOf("\\r\\n.\\r\\n")); s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
// Create binary data with all byte values 0-255
const binary = new Uint8Array(256);
for (let i = 0; i < 256; i++) binary[i] = i;
const r = await c.send({
from: "a@b.com", to: "c@d.com", text: "see attached",
attachments: [{ filename: "binary.bin", content: binary }],
});
const msg = sessions[0].msg;
// The binary content should be base64-encoded in the message
const expectedB64 = Buffer.from(binary).toString("base64");
console.log(JSON.stringify({
sent: r.accepted.length === 1,
hasBase64: msg.includes(expectedB64) || msg.includes(expectedB64.slice(0, 40)),
hasFilename: msg.includes("binary.bin"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.sent).toBe(true);
expect(d.hasFilename).toBe(true);
expect(d.hasBase64).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Large message", () => {
test("should send 50KB text message without corruption", async () => {
const { stdout, exitCode } = await run(`
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { inData: false, buf: "", msg: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.msg = s.data.buf.slice(0, s.data.buf.indexOf("\\r\\n.\\r\\n")); s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
// 50KB of text with a unique marker at the end
const bigText = Buffer.alloc(50000, "X").toString() + "ENDMARKER";
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: bigText });
const msg = sessions[0].msg;
console.log(JSON.stringify({
sent: r.accepted.length === 1,
hasMarker: msg.includes("ENDMARKER"),
msgLen: msg.length,
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.sent).toBe(true);
expect(d.hasMarker).toBe(true);
expect(d.msgLen).toBeGreaterThan(50000);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,200 +0,0 @@
/**
* Security tests: verify CRLF injection is blocked at every user-controlled input.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
const MOCK = `
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { cmds: [], msg: "", inData: false, buf: "" }; sessions.push(s.data); s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.inData) { s.data.buf += t; if (s.data.buf.includes("\\r\\n.\\r\\n")) { s.data.inData = false; s.data.msg = s.data.buf.slice(0, s.data.buf.indexOf("\\r\\n.\\r\\n")); s.data.buf = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
s.data.cmds.push(l);
if (l.startsWith("EHLO") || l.startsWith("LHLO")) s.write("250 OK\\r\\n");
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.inData = true; s.data.buf = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
`;
describe("SMTP command injection prevention", () => {
test("should strip CRLF from EHLO hostname (name option)", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({
host: "127.0.0.1", port: server.port,
name: "legit\\r\\nMAIL FROM:<evil@hacker.com>",
});
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "hi" });
} catch(e) {}
// The key check: the injected text should NOT appear as a SEPARATE command
// (it may appear as part of the EHLO hostname, which is harmless)
const injectedAsCommand = sessions[0].cmds.some(c =>
c.startsWith("MAIL FROM:") && c.includes("evil@hacker.com")
);
console.log(JSON.stringify({ injectedAsCommand }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.injectedAsCommand).toBe(false);
expect(exitCode).toBe(0);
});
test("should prevent CRLF injection in MAIL FROM wire command", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
try {
await c.send({ from: "legit@test.com\\r\\nRCPT TO:<evil@hacker.com>", to: "c@d.com", text: "hi" });
} catch(e) {}
// The key check: no raw CRLF in any SMTP command (writeCmd sanitizes)
const hasRawCRLF = sessions[0].cmds.some(c => c.includes("\\r") || c.includes("\\n"));
console.log(JSON.stringify({ hasRawCRLF }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasRawCRLF).toBe(false);
expect(exitCode).toBe(0);
});
test("should prevent CRLF injection in RCPT TO wire command", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
try {
await c.send({ from: "a@b.com", to: "legit@test.com\\r\\nDATA\\r\\n.\\r\\n", text: "hi" });
} catch(e) {}
// No raw CRLF should appear in any individual command
const hasRawCRLF = sessions[0].cmds.some(c => c.includes("\\r") || c.includes("\\n"));
console.log(JSON.stringify({ hasRawCRLF }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasRawCRLF).toBe(false);
expect(exitCode).toBe(0);
});
});
describe("MIME header injection prevention", () => {
test("should strip CRLF from Subject header", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({
from: "a@b.com", to: "c@d.com",
subject: "Normal\\r\\nBcc: evil@hacker.com",
text: "test",
});
const msg = sessions[0].msg;
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
test("should strip CRLF from custom header values", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "test",
headers: { "X-Custom": "safe\\r\\nBcc: evil@hacker.com" },
});
const msg = sessions[0].msg;
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
test("should strip CRLF from custom header keys", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "test",
headers: { "X-Evil\\r\\nBcc: evil@hacker.com\\r\\nX-Cont": "value" },
});
const msg = sessions[0].msg;
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
test("should strip CRLF from Reply-To header", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "test",
replyTo: "legit@x.com\\r\\nBcc: evil@hacker.com",
});
const msg = sessions[0].msg;
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
test("should strip CRLF from List-Unsubscribe header", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({
from: "a@b.com", to: "c@d.com", text: "test",
list: { unsubscribe: "https://example.com\\r\\nBcc: evil@hacker.com" },
});
const msg = sessions[0].msg;
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
test("should strip CRLF from From display name", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port });
await c.send({
from: '"Evil\\r\\nBcc: evil@hacker.com" <legit@x.com>',
to: "c@d.com", text: "test",
});
const msg = sessions[0].msg;
const hasSeparateBcc = msg.split("\\r\\n").some(line => line.startsWith("Bcc: evil"));
console.log(JSON.stringify({ hasSeparateBcc }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasSeparateBcc).toBe(false);
expect(exitCode).toBe(0);
});
});

View File

@@ -1,455 +0,0 @@
/**
* Tests for previously untested code paths.
* Each test exercises a specific feature that had zero test coverage.
*/
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
async function run(code: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", code],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: stdout.trim(), stderr, exitCode };
}
const MOCK = `
function mock(opts = {}) {
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(socket) {
const s = { cmds: [], msg: "", inData: false, buf: "", authed: false };
sessions.push(s);
socket.data = s;
socket.write("220 mock\\r\\n");
},
data(socket, raw) {
const text = new TextDecoder().decode(raw);
const s = socket.data;
if (s.inData) {
s.buf += text;
if (s.buf.includes("\\r\\n.\\r\\n")) {
s.inData = false;
s.msg = s.buf.split("\\r\\n.\\r\\n")[0];
s.buf = "";
socket.write("250 OK\\r\\n");
}
return;
}
for (const line of text.split("\\r\\n").filter(l => l)) {
s.cmds.push(line);
if (line.startsWith("EHLO") || line.startsWith("HELO")) {
let r = "250-mock\\r\\n";
if (opts.auth) r += "250-AUTH PLAIN LOGIN CRAM-MD5\\r\\n";
r += "250-SIZE 10485760\\r\\n250 OK\\r\\n";
socket.write(r);
} else if (line.startsWith("AUTH PLAIN ")) {
const cred = Buffer.from(line.slice(11), "base64").toString();
const [, user, pass] = cred.split("\\x00");
if (opts.auth && user === opts.auth.user && pass === opts.auth.pass) {
s.authed = true;
socket.write("235 OK\\r\\n");
} else socket.write("535 Bad\\r\\n");
} else if (line === "AUTH LOGIN") {
socket.write("334 VXNlcm5hbWU6\\r\\n");
} else if (line.startsWith("AUTH CRAM-MD5")) {
const challenge = Buffer.from("<test@mock>").toString("base64");
socket.write("334 " + challenge + "\\r\\n");
} else if (line.startsWith("MAIL FROM:")) {
if (opts.rejectFrom) socket.write("550 Rejected\\r\\n");
else socket.write("250 OK\\r\\n");
} else if (line.startsWith("RCPT TO:")) {
socket.write("250 OK\\r\\n");
} else if (line === "DATA") {
s.inData = true; s.buf = "";
socket.write("354 Go\\r\\n");
} else if (line.startsWith("RSET")) socket.write("250 OK\\r\\n");
else if (line === "QUIT") { socket.write("221 Bye\\r\\n"); socket.end(); }
else {
// For CRAM-MD5 response or AUTH LOGIN password
s.authed = true;
socket.write("235 OK\\r\\n");
}
}
},
},
});
return { server, sessions, port: server.port };
}
`;
describe("Header Folding", () => {
test("long subject is folded at 76 chars", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const longSubject = "This is a very long subject line that definitely exceeds seventy-six characters and should be folded";
await c.send({ from: "a@b.com", to: "c@d.com", subject: longSubject, text: "x" });
const m = sessions[0].msg;
const lines = m.split("\\r\\n");
// Find the Subject line and any continuation lines
let subjectFull = "";
let inSubject = false;
for (const line of lines) {
if (line.startsWith("Subject:")) { subjectFull = line; inSubject = true; }
else if (inSubject && (line.startsWith(" ") || line.startsWith("\\t"))) { subjectFull += "\\r\\n" + line; }
else { inSubject = false; }
}
// Subject should be folded - the first line should be <= 76 chars
const firstLine = subjectFull.split("\\r\\n")[0];
console.log(JSON.stringify({
folded: subjectFull.includes("\\r\\n"),
firstLineOk: firstLine.length <= 78, // Allow a tiny bit of slack
containsFullSubject: subjectFull.replace(/\\r\\n\\s/g, " ").includes("seventy-six"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.folded).toBe(true);
expect(d.firstLineOk).toBe(true);
expect(d.containsFullSubject).toBe(true);
expect(exitCode).toBe(0);
});
test("short subject is NOT folded", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com", subject: "Short subject", text: "x" });
const m = sessions[0].msg;
const subjLine = m.split("\\r\\n").find(l => l.startsWith("Subject:"));
console.log(JSON.stringify({ noFold: subjLine === "Subject: Short subject" }));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).noFold).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("CRAM-MD5 Authentication", () => {
test("CRAM-MD5 auth sends challenge response", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
// Mock that only advertises CRAM-MD5 (no PLAIN/LOGIN)
const sessions = [];
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) {
const sess = { cmds: [], msg: "", inData: false, buf: "", authed: false };
sessions.push(sess);
s.data = sess;
s.write("220 mock\\r\\n");
},
data(s, raw) {
const text = new TextDecoder().decode(raw);
const sess = s.data;
if (sess.inData) {
sess.buf += text;
if (sess.buf.includes("\\r\\n.\\r\\n")) {
sess.inData = false; sess.msg = sess.buf.split("\\r\\n.\\r\\n")[0]; sess.buf = "";
s.write("250 OK\\r\\n");
}
return;
}
for (const line of text.split("\\r\\n").filter(l => l)) {
sess.cmds.push(line);
if (line.startsWith("EHLO")) s.write("250-mock\\r\\n250-AUTH CRAM-MD5\\r\\n250 OK\\r\\n");
else if (line === "AUTH CRAM-MD5") {
const challenge = Buffer.from("<unique.challenge@mock>").toString("base64");
s.write("334 " + challenge + "\\r\\n");
}
else if (line.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (line.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (line === "DATA") { sess.inData = true; sess.buf = ""; s.write("354 Go\\r\\n"); }
else if (line.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (line === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
else {
// This is the CRAM-MD5 response - decode and verify format
const decoded = Buffer.from(line, "base64").toString();
// Should be "username hex-digest" format
const parts = decoded.split(" ");
if (parts.length === 2 && parts[0] === "testuser" && parts[1].length === 32) {
sess.authed = true;
s.write("235 OK\\r\\n");
} else {
s.write("535 Bad\\r\\n");
}
}
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port,
auth: { user: "testuser", pass: "testpass" } });
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: "cram test" });
console.log(JSON.stringify({
ok: r.accepted.length === 1,
authed: sessions[0].authed,
hasCramCmd: sessions[0].cmds.includes("AUTH CRAM-MD5"),
noPlain: !sessions[0].cmds.some(c => c.startsWith("AUTH PLAIN")),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.ok).toBe(true);
expect(d.authed).toBe(true);
expect(d.hasCramCmd).toBe(true);
expect(d.noPlain).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Auth Method Override", () => {
test("auth.method forces LOGIN even when PLAIN available", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock({ auth: { user: "u", pass: "p" } });
const c = new Bun.SMTPClient({ host: "127.0.0.1", port,
auth: { user: "u", pass: "p", method: "LOGIN" } });
const r = await c.send({ from: "a@b.com", to: "c@d.com", text: "x" });
console.log(JSON.stringify({
ok: r.accepted.length === 1,
hasLogin: sessions[0].cmds.includes("AUTH LOGIN"),
noPlain: !sessions[0].cmds.some(c => c.startsWith("AUTH PLAIN")),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.ok).toBe(true);
expect(d.hasLogin).toBe(true);
expect(d.noPlain).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Socket Timeout", () => {
test("socketTimeout triggers on idle connection", async () => {
const { stdout, exitCode } = await run(`
// Server that sends greeting but then goes silent
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.write("220 OK\\r\\n"); },
data(s, raw) {
const text = new TextDecoder().decode(raw);
if (text.includes("EHLO")) {
// Send EHLO response but then go completely silent
s.write("250 OK\\r\\n");
// Don't respond to anything else - force timeout
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port,
connectionTimeout: 500 }); // 500ms timeout
const start = Date.now();
try {
await c.send({ from: "a@b.com", to: "c@d.com", text: "x" });
console.log("NO_ERROR");
} catch(e) {
const elapsed = Date.now() - start;
console.log(JSON.stringify({
timedOut: e.message.includes("timeout") || e.code === "ETIMEDOUT",
elapsed: elapsed,
reasonable: elapsed < 3000, // Should timeout within 3s (500ms + overhead)
}));
}
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.timedOut).toBe(true);
expect(d.reasonable).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Large Message Body", () => {
test("should send 100KB message without corruption", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
// Generate a 100KB message body
const body = "A".repeat(100 * 1024);
await c.send({ from: "a@b.com", to: "c@d.com", text: body });
const m = sessions[0].msg;
// Count how many 'A' characters are in the message (QP might encode some)
const bodySection = m.split("\\r\\n\\r\\n").slice(1).join("\\r\\n\\r\\n");
// QP-encoded body of all 'A' should just be 'A' repeated with soft line breaks
const decoded = bodySection.replace(/=\\r\\n/g, ""); // Remove soft line breaks
console.log(JSON.stringify({
hasBody: decoded.length > 50000,
allAs: decoded.split("").every(c => c === "A" || c === "\\r" || c === "\\n"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasBody).toBe(true);
expect(d.allAs).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Dot Stuffing", () => {
test("lines starting with dot are escaped", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com", to: "c@d.com",
raw: "From: a@b.com\\r\\nTo: c@d.com\\r\\n\\r\\n.This line starts with a dot\\r\\n..Two dots\\r\\nNormal line" });
const m = sessions[0].msg;
console.log(JSON.stringify({
// After dot-unstuffing by server, the message should have the original dots
hasDotLine: m.includes(".This line starts with a dot"),
hasTwoDots: m.includes("..Two dots"),
hasNormal: m.includes("Normal line"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.hasDotLine).toBe(true);
expect(d.hasTwoDots).toBe(true);
expect(d.hasNormal).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Address Parser - Security", () => {
test("should not extract email from quoted local-part", async () => {
// Security test from nodemailer: quoted strings can contain @ but should not be extracted
const { stdout, exitCode } = await run(`
const r = Bun.SMTPClient.parseAddress('"xclow3n@gmail.com x"@internal.domain');
// Should NOT route to xclow3n@gmail.com - the whole thing is one address
console.log(JSON.stringify({
count: r.length,
address: r[0]?.address || "",
notGmail: !(r[0]?.address || "").endsWith("@gmail.com"),
}));
`);
const d = JSON.parse(stdout);
expect(d.count).toBe(1);
expect(d.notGmail).toBe(true);
expect(exitCode).toBe(0);
});
test("should handle quoted local-part with attacker domain", async () => {
const { stdout, exitCode } = await run(`
const r = Bun.SMTPClient.parseAddress('"user@attacker.com"@legitimate.com');
console.log(JSON.stringify({
address: r[0]?.address || "",
notAttacker: !(r[0]?.address || "").endsWith("@attacker.com"),
}));
`);
const d = JSON.parse(stdout);
expect(d.notAttacker).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Connection Pool", () => {
test("maxMessages: recycles connection after limit", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
let ehlos = 0;
const server = Bun.listen({
hostname: "127.0.0.1", port: 0,
socket: {
open(s) { s.data = { d: false, b: "" }; s.write("220 OK\\r\\n"); },
data(s, raw) {
const t = new TextDecoder().decode(raw);
if (s.data.d) { s.data.b += t; if (s.data.b.includes("\\r\\n.\\r\\n")) { s.data.d = false; s.data.b = ""; s.write("250 OK\\r\\n"); } return; }
for (const l of t.split("\\r\\n").filter(x=>x)) {
if (l.startsWith("EHLO") || l.startsWith("LHLO")) { ehlos++; s.write("250 OK\\r\\n"); }
else if (l.startsWith("MAIL")) s.write("250 OK\\r\\n");
else if (l.startsWith("RCPT")) s.write("250 OK\\r\\n");
else if (l === "DATA") { s.data.d = true; s.data.b = ""; s.write("354 Go\\r\\n"); }
else if (l.startsWith("RSET")) s.write("250 OK\\r\\n");
else if (l === "QUIT") { s.write("221 Bye\\r\\n"); s.end(); }
}
},
},
});
const c = new Bun.SMTPClient({ host: "127.0.0.1", port: server.port, pool: true, maxMessages: 3 });
for (let i = 0; i < 5; i++) await c.send({ from: "a@b.com", to: "c@d.com", text: "msg" + i });
// With maxMessages=3, after 3 messages the connection should recycle
// So we should see at least 2 EHLOs (initial + reconnect after 3rd message)
console.log(JSON.stringify({ ehlos, reconnected: ehlos >= 2 }));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.reconnected).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Custom Header Folding", () => {
test("long custom header is folded", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
const longValue = "This is a very long custom header value that definitely exceeds the seventy-six character line limit and should be folded by the MIME builder";
await c.send({ from: "a@b.com", to: "c@d.com", text: "x",
headers: { "X-Long-Header": longValue } });
const m = sessions[0].msg;
// Find the X-Long-Header and check if it's folded
const headerStart = m.indexOf("X-Long-Header:");
const headerEnd = m.indexOf("\\r\\n", headerStart + 20);
const firstLine = m.substring(headerStart, headerEnd);
console.log(JSON.stringify({
folded: m.indexOf("\\r\\n ", headerStart) < headerEnd + 10, // Continuation line within header
containsValue: m.includes("seventy-six"),
}));
c.close(); server.stop();
`);
const d = JSON.parse(stdout);
expect(d.folded).toBe(true);
expect(d.containsValue).toBe(true);
expect(exitCode).toBe(0);
});
test("long To header with many recipients is folded", async () => {
const { stdout, exitCode } = await run(`
${MOCK}
const { server, sessions, port } = mock();
const c = new Bun.SMTPClient({ host: "127.0.0.1", port });
await c.send({ from: "a@b.com",
to: ["alice@example.com", "bob@example.com", "charlie@example.com", "david@example.com", "eve@example.com"],
text: "x" });
const m = sessions[0].msg;
// To header should contain all recipients and be folded if long enough
const toHeader = m.substring(m.indexOf("To:"), m.indexOf("\\r\\n", m.indexOf("To:") + 70) + 10);
console.log(JSON.stringify({
hasAll: m.includes("alice@") && m.includes("bob@") && m.includes("charlie@") && m.includes("david@") && m.includes("eve@"),
}));
c.close(); server.stop();
`);
expect(JSON.parse(stdout).hasAll).toBe(true);
expect(exitCode).toBe(0);
});
});
describe("Address Parser - Groups", () => {
test("nested groups are flattened", async () => {
const { stdout, exitCode } = await run(`
const r = Bun.SMTPClient.parseAddress("Outer:Inner:deep@example.com;;");
console.log(JSON.stringify({
count: r.length,
hasGroup: r.length > 0 && !!r[0].group,
}));
`);
const d = JSON.parse(stdout);
expect(d.count).toBe(1);
expect(d.hasGroup).toBe(true);
expect(exitCode).toBe(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import { once } from "node:events";
import { createServer } from "node:net";
describe("fetch with many headers", () => {
test("should not crash or corrupt memory with more than 256 headers", async () => {
// Use a raw TCP server to avoid uws header count limits on the server side.
// We just need to verify that the client sends the request without crashing.
await using server = createServer(socket => {
let data = "";
socket.on("data", (chunk: Buffer) => {
data += chunk.toString();
// Wait for the end of HTTP headers (double CRLF)
if (data.includes("\r\n\r\n")) {
// Count headers (lines between the request line and the blank line)
const headerSection = data.split("\r\n\r\n")[0];
const lines = headerSection.split("\r\n");
// First line is the request line (GET / HTTP/1.1), rest are headers
const headerCount = lines.length - 1;
const body = String(headerCount);
const response = ["HTTP/1.1 200 OK", `Content-Length: ${body.length}`, "Connection: close", "", body].join(
"\r\n",
);
socket.write(response);
socket.end();
}
});
}).listen(0);
await once(server, "listening");
const port = (server.address() as any).port;
// Build 300 unique custom headers (exceeds the 256-entry static buffer)
const headers = new Headers();
const headerCount = 300;
for (let i = 0; i < headerCount; i++) {
headers.set(`x-custom-${i}`, `value-${i}`);
}
const res = await fetch(`http://localhost:${port}/`, { headers });
const receivedCount = parseInt(await res.text(), 10);
expect(res.status).toBe(200);
// The server should receive our custom headers plus default ones
// (host, connection, user-agent, accept, accept-encoding = 5 extra)
expect(receivedCount).toBeGreaterThanOrEqual(headerCount);
});
test("should handle exactly 256 user headers without issues", async () => {
await using server = createServer(socket => {
let data = "";
socket.on("data", (chunk: Buffer) => {
data += chunk.toString();
if (data.includes("\r\n\r\n")) {
const headerSection = data.split("\r\n\r\n")[0];
const lines = headerSection.split("\r\n");
const headerCount = lines.length - 1;
const body = String(headerCount);
const response = ["HTTP/1.1 200 OK", `Content-Length: ${body.length}`, "Connection: close", "", body].join(
"\r\n",
);
socket.write(response);
socket.end();
}
});
}).listen(0);
await once(server, "listening");
const port = (server.address() as any).port;
const headers = new Headers();
const headerCount = 256;
for (let i = 0; i < headerCount; i++) {
headers.set(`x-custom-${i}`, `value-${i}`);
}
const res = await fetch(`http://localhost:${port}/`, { headers });
const receivedCount = parseInt(await res.text(), 10);
expect(res.status).toBe(200);
expect(receivedCount).toBeGreaterThanOrEqual(headerCount);
});
});

View File

@@ -1,222 +0,0 @@
import { TCPSocketListener } from "bun";
import { describe, expect, test } from "bun:test";
const hostname = "127.0.0.1";
const MAX_HEADER_SIZE = 16 * 1024;
function doHandshake(
socket: any,
handshakeBuffer: Uint8Array,
data: Uint8Array,
): { buffer: Uint8Array; done: boolean } {
const newBuffer = new Uint8Array(handshakeBuffer.length + data.length);
newBuffer.set(handshakeBuffer);
newBuffer.set(data, handshakeBuffer.length);
if (newBuffer.length > MAX_HEADER_SIZE) {
socket.end();
throw new Error("Handshake headers too large");
}
const dataStr = new TextDecoder("utf-8").decode(newBuffer);
const endOfHeaders = dataStr.indexOf("\r\n\r\n");
if (endOfHeaders === -1) {
return { buffer: newBuffer, done: false };
}
if (!dataStr.startsWith("GET")) {
throw new Error("Invalid handshake");
}
const magic = /Sec-WebSocket-Key:\s*(.*)\r\n/i.exec(dataStr);
if (!magic) {
throw new Error("Missing Sec-WebSocket-Key");
}
const hasher = new Bun.CryptoHasher("sha1");
hasher.update(magic[1].trim());
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
const accept = hasher.digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n` +
"\r\n",
);
socket.flush();
return { buffer: newBuffer, done: true };
}
function makeTextFrame(text: string): Uint8Array {
const payload = new TextEncoder().encode(text);
const len = payload.length;
let header: Uint8Array;
if (len < 126) {
header = new Uint8Array([0x81, len]);
} else if (len < 65536) {
header = new Uint8Array([0x81, 126, (len >> 8) & 0xff, len & 0xff]);
} else {
throw new Error("Message too large for this test");
}
const frame = new Uint8Array(header.length + len);
frame.set(header);
frame.set(payload, header.length);
return frame;
}
describe("WebSocket", () => {
test("fragmented pong frame does not cause frame desync", async () => {
let server: TCPSocketListener | undefined;
let client: WebSocket | undefined;
let handshakeBuffer = new Uint8Array(0);
let handshakeComplete = false;
try {
const { promise, resolve, reject } = Promise.withResolvers<void>();
server = Bun.listen({
socket: {
data(socket, data) {
if (handshakeComplete) {
// After handshake, we just receive client frames (like close) - ignore them
return;
}
const result = doHandshake(socket, handshakeBuffer, new Uint8Array(data));
handshakeBuffer = result.buffer;
if (!result.done) return;
handshakeComplete = true;
// Build a pong frame with a 50-byte payload, but deliver it in two parts.
// Pong opcode = 0x8A, FIN=1
const pongPayload = new Uint8Array(50);
for (let i = 0; i < 50; i++) pongPayload[i] = 0x41 + (i % 26); // 'A'-'Z' repeated
const pongFrame = new Uint8Array(2 + 50);
pongFrame[0] = 0x8a; // FIN + Pong opcode
pongFrame[1] = 50; // payload length
pongFrame.set(pongPayload, 2);
// Part 1 of pong: header (2 bytes) + first 2 bytes of payload = 4 bytes
// This leaves 48 bytes of pong payload undelivered.
const pongPart1 = pongFrame.slice(0, 4);
// Part 2: remaining 48 bytes of pong payload
const pongPart2 = pongFrame.slice(4);
// A text message to send after the pong completes.
const textFrame = makeTextFrame("hello after pong");
// Send part 1 of pong
socket.write(pongPart1);
socket.flush();
// After a delay, send part 2 of pong + the follow-up text message
setTimeout(() => {
// Concatenate part2 + text frame to simulate them arriving together
const combined = new Uint8Array(pongPart2.length + textFrame.length);
combined.set(pongPart2);
combined.set(textFrame, pongPart2.length);
socket.write(combined);
socket.flush();
}, 50);
},
},
hostname,
port: 0,
});
const messages: string[] = [];
client = new WebSocket(`ws://${server.hostname}:${server.port}`);
client.addEventListener("error", event => {
reject(new Error("WebSocket error"));
});
client.addEventListener("close", event => {
// If the connection closes unexpectedly due to frame desync, the test should fail
reject(new Error(`WebSocket closed unexpectedly: code=${event.code} reason=${event.reason}`));
});
client.addEventListener("message", event => {
messages.push(event.data as string);
if (messages.length === 1) {
// We got the text message after the fragmented pong
try {
expect(messages[0]).toBe("hello after pong");
resolve();
} catch (err) {
reject(err);
}
}
});
await promise;
} finally {
client?.close();
server?.stop(true);
}
});
test("pong frame with payload > 125 bytes is rejected", async () => {
let server: TCPSocketListener | undefined;
let client: WebSocket | undefined;
let handshakeBuffer = new Uint8Array(0);
let handshakeComplete = false;
try {
const { promise, resolve, reject } = Promise.withResolvers<void>();
server = Bun.listen({
socket: {
data(socket, data) {
if (handshakeComplete) return;
const result = doHandshake(socket, handshakeBuffer, new Uint8Array(data));
handshakeBuffer = result.buffer;
if (!result.done) return;
handshakeComplete = true;
// Send a pong frame with a 126-byte payload (invalid per RFC 6455 Section 5.5)
// Control frames MUST have a payload length of 125 bytes or less.
// Use 2-byte extended length encoding since 126 > 125.
// But actually, the 7-bit length field in byte[1] can encode 0-125 directly.
// For 126, the server must use the extended 16-bit length.
// However, control frames with >125 payload are invalid regardless of encoding.
const pongFrame = new Uint8Array(4 + 126);
pongFrame[0] = 0x8a; // FIN + Pong
pongFrame[1] = 126; // Signals 16-bit extended length follows
pongFrame[2] = 0; // High byte of length
pongFrame[3] = 126; // Low byte of length = 126
// Fill payload with arbitrary data
for (let i = 0; i < 126; i++) pongFrame[4 + i] = 0x42;
socket.write(pongFrame);
socket.flush();
},
},
hostname,
port: 0,
});
client = new WebSocket(`ws://${server.hostname}:${server.port}`);
client.addEventListener("error", () => {
// Expected - the connection should error due to invalid control frame
resolve();
});
client.addEventListener("close", () => {
// Also acceptable - connection closes due to protocol error
resolve();
});
client.addEventListener("message", () => {
reject(new Error("Should not receive a message from an invalid pong frame"));
});
await promise;
} finally {
client?.close();
server?.stop(true);
}
});
});

View File

@@ -1,77 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("--bail writes JUnit reporter outfile", async () => {
using dir = tempDir("bail-junit", {
"fail.test.ts": `
import { test, expect } from "bun:test";
test("failing test", () => { expect(1).toBe(2); });
`,
});
const outfile = join(String(dir), "results.xml");
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`, "fail.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// The test should fail and bail
expect(exitCode).not.toBe(0);
// The JUnit report file should still be written despite bail
const file = Bun.file(outfile);
expect(await file.exists()).toBe(true);
const xml = await file.text();
expect(xml).toContain("<?xml");
expect(xml).toContain("<testsuites");
expect(xml).toContain("</testsuites>");
expect(xml).toContain("failing test");
});
test("--bail writes JUnit reporter outfile with multiple files", async () => {
using dir = tempDir("bail-junit-multi", {
"a_pass.test.ts": `
import { test, expect } from "bun:test";
test("passing test", () => { expect(1).toBe(1); });
`,
"b_fail.test.ts": `
import { test, expect } from "bun:test";
test("another failing test", () => { expect(1).toBe(2); });
`,
});
const outfile = join(String(dir), "results.xml");
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// The test should fail and bail
expect(exitCode).not.toBe(0);
// The JUnit report file should still be written despite bail
const file = Bun.file(outfile);
expect(await file.exists()).toBe(true);
const xml = await file.text();
expect(xml).toContain("<?xml");
expect(xml).toContain("<testsuites");
expect(xml).toContain("</testsuites>");
// Both the passing and failing tests should be recorded
expect(xml).toContain("passing test");
expect(xml).toContain("another failing test");
});

View File

@@ -1,187 +0,0 @@
import { SQL } from "bun";
import { expect, test } from "bun:test";
import net from "net";
test("postgres connection rejects null bytes in username", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice\x00search_path\x00evil_schema,public",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
// The server should never have received any data because the null byte
// should be rejected before the connection is established.
expect(serverReceivedData).toBe(false);
});
test("postgres connection rejects null bytes in database", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb\x00search_path\x00evil_schema,public",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
expect(serverReceivedData).toBe(false);
});
test("postgres connection rejects null bytes in password", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
password: "pass\x00search_path\x00evil_schema",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
expect(serverReceivedData).toBe(false);
});
test("postgres connection does not use truncated path with null bytes", async () => {
// The JS layer's fs.existsSync() rejects paths containing null bytes,
// so the path is dropped before reaching the native layer. Verify that a
// path with null bytes doesn't silently connect via a truncated path.
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb",
path: "/tmp\x00injected",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
} catch {
// Expected to fail
} finally {
server.close();
}
// The path had null bytes so it should have been dropped by the JS layer,
// falling back to TCP where it hits our mock server (not a truncated Unix socket).
expect(serverReceivedData).toBe(true);
});
test("postgres connection works with normal parameters (no null bytes)", async () => {
// Verify that normal connections without null bytes still work.
// Use a mock server that sends an auth error so we can verify the
// startup message is sent correctly.
let receivedData = false;
const server = net.createServer(socket => {
socket.once("data", () => {
receivedData = true;
const errMsg = Buffer.from("SFATAL\0VFATAL\0C28000\0Mauthentication failed\0\0");
const len = errMsg.length + 4;
const header = Buffer.alloc(5);
header.write("E", 0);
header.writeInt32BE(len, 1);
socket.write(Buffer.concat([header, errMsg]));
socket.destroy();
});
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
} catch {
// Expected - mock server sends auth error
} finally {
server.close();
}
// Normal parameters should connect fine - the server should receive data
expect(receivedData).toBe(true);
});

View File

@@ -1,148 +0,0 @@
import { S3Client } from "bun";
import { describe, expect, test } from "bun:test";
// Test that CRLF characters in S3 options are rejected to prevent header injection.
// See: HTTP Header Injection via S3 Content-Disposition Value
describe("S3 header injection prevention", () => {
test("contentDisposition with CRLF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: 'attachment; filename="evil"\r\nX-Injected: value',
}),
).toThrow(/CR\/LF/);
});
test("contentEncoding with CRLF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentEncoding: "gzip\r\nX-Injected: value",
}),
).toThrow(/CR\/LF/);
});
test("type (content-type) with CRLF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
type: "text/plain\r\nX-Injected: value",
}),
).toThrow(/CR\/LF/);
});
test("contentDisposition with only CR should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: "attachment\rinjected",
}),
).toThrow(/CR\/LF/);
});
test("contentDisposition with only LF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: "attachment\ninjected",
}),
).toThrow(/CR\/LF/);
});
test("valid contentDisposition without CRLF should not throw", async () => {
const { promise: requestReceived, resolve: onRequestReceived } = Promise.withResolvers<Headers>();
using server = Bun.serve({
port: 0,
async fetch(req) {
onRequestReceived(req.headers);
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
// Valid content-disposition values should not throw synchronously.
// The write may eventually fail because the mock server doesn't speak S3 protocol,
// but the option parsing should succeed and a request should be initiated.
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: 'attachment; filename="report.pdf"',
}),
).not.toThrow();
const receivedHeaders = await requestReceived;
expect(receivedHeaders.get("content-disposition")).toBe('attachment; filename="report.pdf"');
});
});