diff --git a/go.mod b/go.mod index f56c9fd7..a3c3f110 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5 github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index a357f88a..0defc5a0 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5 h1:5WuRAb8NO7UCHTVUel5LmZfI0A76obGtDOYAzlhrKmM= +github.com/go-ruby-net-smtp/net-smtp v0.0.0-20260630142921-0151ad2e87f5/go.mod h1:+XJbVnPobgkWUscH8bDSw9F3jW6t3QY35DEAyE9qE9Q= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..f459a050 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -478,6 +478,7 @@ func (vm *VM) bootstrap() { vm.registerSQLite3() // SQLite3::Database/Statement (require "sqlite3"), backed by go-ruby-sqlite3 (modernc); needs StandardError for SQLite3::Exception vm.registerRedis() // Redis client (require "redis"), backed by go-ruby-redis RESP codec; socket = injected IO seam; needs StandardError for Redis::BaseError vm.registerPG() // PG::Connection/Result (require "pg"), backed by go-ruby-pg v3 protocol; socket = injected IO seam; needs StandardError for PG::Error + vm.registerNetSMTP() // Net::SMTP (require "net/smtp"), backed by go-ruby-net-smtp codec over rbgo's real socket/TLS transport; after registerSocket/registerNetHTTP (Net module + transport) vm.registerSidekiq() // Sidekiq::Client/Job(Worker) + module (require "sidekiq"), backed by go-ruby-sidekiq over go-redis/v9; Redis client dialled from Sidekiq.redis/ENV; perform body is Ruby seam; needs StandardError for Sidekiq::Error vm.registerResque() // Resque module + Job/Worker/Failure (require "resque"), backed by go-ruby-resque over go-redis/v9; @queue resolver + self.perform are Ruby seams; needs StandardError for Resque::Error vm.registerSequel() // Sequel query builder + Database (require "sequel"), backed by go-ruby-sequel; executor seam wired to SQLite3::Database (real execution) diff --git a/internal/vm/net_smtp.go b/internal/vm/net_smtp.go new file mode 100644 index 00000000..19790cca --- /dev/null +++ b/internal/vm/net_smtp.go @@ -0,0 +1,750 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + "time" + + netsmtp "github.com/go-ruby-net-smtp/net-smtp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerNetSMTP installs Net::SMTP (require "net/smtp"): the session class, its +// Net::SMTP::Response view and message-stream adapter, and the Net::SMTPError +// exception family. The SMTP protocol (commands, replies, DATA dot-stuffing, SASL +// auth) is github.com/go-ruby-net-smtp/net-smtp; the byte I/O is rbgo's own +// socket transport (see net_smtp_bind.go). It runs after registerSocket / +// registerOpenSSL (the TCP + TLS layers it dials and upgrades through) and after +// registerNetHTTP (which creates the Net module and its transport helpers). +func (vm *VM) registerNetSMTP() { + netMod := vm.consts["Net"].(*RClass) + vm.registerNetSMTPErrors(netMod) + + smtp := newClass("Net::SMTP", vm.cObject) + netMod.consts["SMTP"] = smtp + vm.consts["Net::SMTP"] = smtp + + vm.registerSMTPResponse(smtp) + vm.registerSMTPStream(smtp) + vm.registerNetSMTPClassMethods(smtp) + vm.registerNetSMTPConfig(smtp) + vm.registerNetSMTPProtocol(smtp) +} + +// registerNetSMTPErrors installs the Net::SMTPError family (SMTPError < +// ProtocolError < StandardError; the five reply-keyed kinds plus +// SMTPUnsupportedCommand < SMTPError), the classes Net::SMTP raises via +// check_response. Each maps one ErrorKind.RubyClass() name so a raised codec +// error rescues as its MRI-faithful class. +func (vm *VM) registerNetSMTPErrors(netMod *RClass) { + std := vm.consts["StandardError"].(*RClass) + proto := newClass("Net::ProtocolError", std) + netMod.consts["ProtocolError"] = proto + vm.consts["Net::ProtocolError"] = proto + reg := func(simple string, super *RClass) *RClass { + qualified := "Net::" + simple + c := newClass(qualified, super) + netMod.consts[simple] = c + vm.consts[qualified] = c + return c + } + base := reg("SMTPError", proto) + reg("SMTPUnknownError", base) + reg("SMTPServerBusy", base) + reg("SMTPSyntaxError", base) + reg("SMTPAuthenticationError", base) + reg("SMTPFatalError", base) + reg("SMTPUnsupportedCommand", base) +} + +// SMTPResponseObj is the Ruby wrapper around a *netsmtp.Response (Net::SMTP::Response): +// the reply status, its full text, and the success?/continue? predicates. +type SMTPResponseObj struct { + cls *RClass + res *netsmtp.Response +} + +func (r *SMTPResponseObj) ToS() string { return r.res.String } +func (r *SMTPResponseObj) Inspect() string { return "#" } +func (r *SMTPResponseObj) Truthy() bool { return true } + +// smtpResponse wraps a codec reply in a Net::SMTP::Response Ruby object. +func (vm *VM) smtpResponse(res *netsmtp.Response) object.Value { + return &SMTPResponseObj{cls: vm.consts["Net::SMTP::Response"].(*RClass), res: res} +} + +// registerSMTPResponse installs Net::SMTP::Response (status / string / message / +// success? / continue?), mirroring the reply object MRI returns from +// send_message and the command helpers. +func (vm *VM) registerSMTPResponse(smtp *RClass) { + rc := newClass("Net::SMTP::Response", vm.cObject) + smtp.consts["Response"] = rc + vm.consts["Net::SMTP::Response"] = rc + res := func(v object.Value) *netsmtp.Response { return v.(*SMTPResponseObj).res } + rc.define("status", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(res(self).Status) + }) + rc.define("string", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(res(self).String) + }) + rc.define("message", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(res(self).Message()) + }) + rc.define("success?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(res(self).Success()) + }) + rc.define("continue?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(res(self).Continue()) + }) +} + +// SMTPStreamObj is the writable adapter Net::SMTP#open_message_stream (and the +// block form of #data) yields: #print / #puts / #write / #<< append to a buffer +// that becomes the DATA payload once the block returns. +type SMTPStreamObj struct { + cls *RClass + buf *strings.Builder +} + +func (s *SMTPStreamObj) ToS() string { return "#" } +func (s *SMTPStreamObj) Inspect() string { return "#" } +func (s *SMTPStreamObj) Truthy() bool { return true } + +// newSMTPStream builds a fresh message-stream adapter. +func (vm *VM) newSMTPStream() *SMTPStreamObj { + return &SMTPStreamObj{cls: vm.consts["Net::SMTP::Adapter"].(*RClass), buf: &strings.Builder{}} +} + +// registerSMTPStream installs the Net::SMTP::Adapter message-stream class with the +// IO-like write surface open_message_stream yields. +func (vm *VM) registerSMTPStream(smtp *RClass) { + rc := newClass("Net::SMTP::Adapter", vm.cObject) + smtp.consts["Adapter"] = rc + vm.consts["Net::SMTP::Adapter"] = rc + buf := func(v object.Value) *strings.Builder { return v.(*SMTPStreamObj).buf } + + // #write(*args) appends each argument and returns the total byte count. + rc.define("write", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := buf(self) + n := 0 + for _, a := range args { + s := smtpStr(a) + b.WriteString(s) + n += len(s) + } + return object.IntValue(int64(n)) + }) + // #print(*args) appends each argument without a separator and returns nil. + rc.define("print", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := buf(self) + for _, a := range args { + b.WriteString(smtpStr(a)) + } + return object.NilV + }) + // #puts(*args) appends each argument, adding a newline where one is missing; + // with no argument it appends a lone newline (Ruby IO#puts). + rc.define("puts", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + b := buf(self) + if len(args) == 0 { + b.WriteString("\n") + return object.NilV + } + for _, a := range args { + s := smtpStr(a) + b.WriteString(s) + if !strings.HasSuffix(s, "\n") { + b.WriteString("\n") + } + } + return object.NilV + }) + // #<<(obj) appends obj and returns self for chaining (Ruby IO#<<). + rc.define("<<", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + buf(self).WriteString(smtpStr(args[0])) + return self + }) +} + +// registerNetSMTPClassMethods installs Net::SMTP.new / .start plus the default +// port constants. +func (vm *VM) registerNetSMTPClassMethods(smtp *RClass) { + sm := func(name string, fn NativeFn) { + smtp.smethods[name] = &Method{name: name, owner: smtp, native: fn} + } + sm("new", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpNew(smtp, args) + }) + sm("start", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + return vm.smtpClassStart(smtp, args, blk) + }) + port := func(name string, n int64) { + sm(name, func(_ *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(n) + }) + } + port("default_port", 25) + port("default_tls_port", 465) + port("default_ssl_port", 465) + port("default_submission_port", 587) +} + +// smtpNew builds an unstarted Net::SMTP for an address and optional port +// (default 25), with the modern defaults (ESMTP on, STARTTLS auto). +func (vm *VM) smtpNew(cls *RClass, args []object.Value) *SMTPObj { + if len(args) < 1 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1..2)") + } + o := &SMTPObj{cls: cls, address: strArg(args[0]), port: "25", esmtp: true, starttls: smtpStartTLSAuto} + if len(args) >= 2 && !object.IsNil(args[1]) { + o.port = portString(args[1]) + } + return o +} + +// smtpClassStart implements Net::SMTP.start: build an instance, open the session, +// and either yield it to a block (closing it afterwards) or return it started. +// The address/port and the helo/user/secret/authtype credentials come from the +// positional arguments; a trailing options Hash may instead carry them by keyword +// (helo:, user:, secret:/password:, authtype:) along with tls: / starttls:. +func (vm *VM) smtpClassStart(cls *RClass, args []object.Value, blk *Proc) object.Value { + kw, args := smtpSplitKw(args) + if len(args) < 1 { + raise("ArgumentError", "wrong number of arguments (given 0, expected 1+)") + } + o := &SMTPObj{cls: cls, address: strArg(args[0]), port: "25", esmtp: true, starttls: smtpStartTLSAuto} + if len(args) >= 2 && !object.IsNil(args[1]) { + o.port = portString(args[1]) + } + var rest []object.Value + if len(args) > 2 { + rest = args[2:] + } + helo, user, secret, authtype := vm.smtpStartCreds(rest, kw, o) + vm.smtpDoStart(o, helo, user, secret, authtype) + return vm.smtpYield(o, blk) +} + +// smtpSplitKw peels a trailing options Hash off an argument list. +func smtpSplitKw(args []object.Value) (*object.Hash, []object.Value) { + if len(args) > 0 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + return h, args[:len(args)-1] + } + } + return nil, args +} + +// smtpStartCreds reads the helo domain and credentials from the (post-address) +// positional tail and/or the keyword Hash, and applies the tls:/starttls: +// keywords to o. rest is the positionals after address/port: helo, user, secret, +// authtype. +func (vm *VM) smtpStartCreds(rest []object.Value, kw *object.Hash, o *SMTPObj) (helo string, user, secret, authtype object.Value) { + helo, user, secret, authtype = "localhost", object.NilV, object.NilV, object.NilV + if len(rest) >= 1 && !object.IsNil(rest[0]) { + helo = strArg(rest[0]) + } + if len(rest) >= 2 { + user = rest[1] + } + if len(rest) >= 3 { + secret = rest[2] + } + if len(rest) >= 4 { + authtype = rest[3] + } + if kw != nil { + if v, ok := smtpKw(kw, "helo"); ok && !object.IsNil(v) { + helo = strArg(v) + } + if v, ok := smtpKw(kw, "user"); ok { + user = v + } + if v, ok := smtpKw(kw, "secret"); ok { + secret = v + } + if v, ok := smtpKw(kw, "password"); ok { + secret = v + } + if v, ok := smtpKw(kw, "authtype"); ok { + authtype = v + } + if v, ok := smtpKw(kw, "tls"); ok && v.Truthy() { + o.tls = true + } + if v, ok := smtpKw(kw, "starttls"); ok { + o.starttls = smtpStartTLSMode(v) + } + } + return helo, user, secret, authtype +} + +// smtpYield closes over the started session: with a block it yields the instance, +// finishes the session and returns the block's value; without one it returns the +// started instance. +func (vm *VM) smtpYield(o *SMTPObj, blk *Proc) object.Value { + if blk != nil { + res := vm.callBlock(blk, []object.Value{o}) + vm.smtpFinishQuiet(o) + return res + } + return o +} + +// smtpDoStart opens the session (Net::SMTP#do_start): dial, read and check the +// greeting, EHLO (falling back to HELO), optional STARTTLS + re-EHLO, and +// optional AUTH. Any failure closes the socket before raising. +func (vm *VM) smtpDoStart(o *SMTPObj, helo string, user, secret, authtype object.Value) { + if o.started { + raise("IOError", "SMTP session already started") + } + if err := vm.smtpDial(o); err != nil { + raise("SocketError", "%s", err.Error()) + } + o.deadline() + greeting, err := o.sess.RecvResponse() + if err != nil { + o.closeConn() + smtpRaiseErr(err) + } + if !greeting.Success() { + o.closeConn() + smtpRaiseErr(smtpRespErr(greeting)) + } + vm.smtpHello(o, helo) + if !o.tls && (o.starttls == smtpStartTLSAlways || (o.starttls == smtpStartTLSAuto && o.capableStartTLS())) { + if _, err := o.sess.StartTLS(); err != nil { + o.closeConn() + smtpRaiseErr(err) + } + vm.smtpHello(o, helo) + } + if !object.IsNil(user) && !object.IsNil(secret) { + vm.smtpAuthenticate(o, strArg(user), strArg(secret), authtype) + } + o.started = true +} + +// smtpHello sends EHLO (Net::SMTP#do_helo): on an ESMTP session it EHLOs and, if +// the server refuses, falls back to HELO and drops out of ESMTP mode; a +// non-ESMTP session HELOs directly. A transport error (not an SMTP reply) closes +// the socket and raises. +func (vm *VM) smtpHello(o *SMTPObj, helo string) { + o.deadline() + if o.esmtp { + if _, err := o.sess.Ehlo(helo); err != nil { + if _, ok := err.(*netsmtp.SMTPError); !ok { + o.closeConn() + smtpRaiseErr(err) + } + o.esmtp = false + if _, herr := o.sess.Helo(helo); herr != nil { + o.closeConn() + smtpRaiseErr(herr) + } + } + return + } + if _, err := o.sess.Helo(helo); err != nil { + o.closeConn() + smtpRaiseErr(err) + } +} + +// smtpAuthenticate runs the SASL exchange for the given authtype (default :plain), +// mapping to the codec's PLAIN / LOGIN / CRAM-MD5 driver. An unknown type raises +// ArgumentError; an auth failure closes the socket and raises the codec error. +func (vm *VM) smtpAuthenticate(o *SMTPObj, user, secret string, authtype object.Value) { + kind := "plain" + if !object.IsNil(authtype) { + kind = smtpAuthTypeName(authtype) + } + o.deadline() + var err error + switch kind { + case "plain": + _, err = o.sess.AuthPlain(user, secret) + case "login": + _, err = o.sess.AuthLogin(user, secret) + case "cram_md5": + _, err = o.sess.AuthCramMD5(user, secret) + default: + raise("ArgumentError", "unknown auth type: %s", kind) + } + if err != nil { + o.closeConn() + smtpRaiseErr(err) + } +} + +// smtpFinishQuiet closes a session (Net::SMTP#do_finish): QUIT best-effort, close +// the socket and clear the driver. +func (vm *VM) smtpFinishQuiet(o *SMTPObj) { + if o.sess != nil { + _, _ = o.sess.Quit() + } + o.closeConn() + o.started = false + o.sess = nil + o.conn = nil +} + +// registerNetSMTPConfig installs the configuration surface: the address/port +// readers, the ESMTP toggle, the TLS/STARTTLS mode setters and predicates, the +// timeouts, the started? flag, and the capability predicates. +func (vm *VM) registerNetSMTPConfig(smtp *RClass) { + obj := func(v object.Value) *SMTPObj { return v.(*SMTPObj) } + d := func(name string, fn NativeFn) { smtp.define(name, fn) } + + d("address", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(obj(self).address) + }) + d("port", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(obj(self).port) + }) + d("started?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).started) + }) + d("esmtp?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).esmtp) + }) + d("esmtp", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).esmtp) + }) + d("esmtp=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + obj(self).esmtp = args[0].Truthy() + return args[0] + }) + + // TLS / STARTTLS state. + d("tls?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).tls) + }) + d("ssl?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).tls) + }) + d("starttls?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).starttls != smtpStartTLSOff) + }) + d("starttls_auto?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).starttls == smtpStartTLSAuto) + }) + d("starttls_always?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).starttls == smtpStartTLSAlways) + }) + + // enable_tls / enable_ssl: implicit TLS on connect; exclusive with STARTTLS-always. + enableTLS := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if o.starttls == smtpStartTLSAlways { + raise("ArgumentError", "SMTPS and STARTTLS is exclusive") + } + o.tls = true + return object.NilV + } + d("enable_tls", enableTLS) + d("enable_ssl", enableTLS) + disableTLS := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + obj(self).tls = false + return object.NilV + } + d("disable_tls", disableTLS) + d("disable_ssl", disableTLS) + + // enable_starttls[_auto] / disable_starttls: exclusive with implicit TLS. + d("enable_starttls", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if o.tls { + raise("ArgumentError", "SMTPS and STARTTLS is exclusive") + } + o.starttls = smtpStartTLSAlways + return object.NilV + }) + d("enable_starttls_auto", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if o.tls { + raise("ArgumentError", "SMTPS and STARTTLS is exclusive") + } + o.starttls = smtpStartTLSAuto + return object.NilV + }) + d("disable_starttls", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + obj(self).starttls = smtpStartTLSOff + return object.NilV + }) + + // Timeouts (seconds); the reader returns the last set value. + d("open_timeout=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + obj(self).openTO = nethttpDuration(args[0]) + return args[0] + }) + d("open_timeout", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return smtpDurationValue(obj(self).openTO) + }) + d("read_timeout=", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + obj(self).readTO = nethttpDuration(args[0]) + return args[0] + }) + d("read_timeout", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return smtpDurationValue(obj(self).readTO) + }) + + // Capability predicates (valid after a started session's EHLO). + d("capable_starttls?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableStartTLS()) + }) + d("capable_plain_auth?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableAuth("PLAIN")) + }) + d("capable_login_auth?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableAuth("LOGIN")) + }) + d("capable_cram_md5_auth?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(obj(self).capableAuth("CRAM-MD5")) + }) + d("capable_auth_types", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + var elems []object.Value + if o.sess != nil { + for _, a := range o.sess.Capabilities()["AUTH"] { + elems = append(elems, object.NewString(a)) + } + } + return object.NewArrayFromSlice(elems) + }) +} + +// smtpDurationValue renders a stored timeout back as a Ruby value: nil when unset, +// else the whole/fractional seconds. +func smtpDurationValue(d time.Duration) object.Value { + if d <= 0 { + return object.NilV + } + secs := d.Seconds() + if secs == float64(int64(secs)) { + return object.IntValue(int64(secs)) + } + return object.Float(secs) +} + +// registerNetSMTPProtocol installs the session lifecycle (start / finish) and the +// command surface (send_message / sendmail / open_message_stream / data / the +// low-level ehlo/helo/mailfrom/rcptto/rset and authenticate). +func (vm *VM) registerNetSMTPProtocol(smtp *RClass) { + obj := func(v object.Value) *SMTPObj { return v.(*SMTPObj) } + d := func(name string, fn NativeFn) { smtp.define(name, fn) } + + // #start(helo='localhost', user=nil, secret=nil, authtype=nil) [{ |smtp| }]. + d("start", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := obj(self) + kw, rest := smtpSplitKw(args) + helo, user, secret, authtype := vm.smtpStartCreds(rest, kw, o) + vm.smtpDoStart(o, helo, user, secret, authtype) + return vm.smtpYield(o, blk) + }) + d("finish", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + o := obj(self) + if !o.started { + raise("IOError", "not yet started") + } + vm.smtpFinishQuiet(o) + return object.NilV + }) + + // #send_message(msgstr, from_addr, *to_addrs) / #sendmail alias. + send := func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpSendMessage(obj(self), args) + } + d("send_message", send) + d("sendmail", send) + + // #open_message_stream(from_addr, to_addrs) { |stream| } / #ready alias. + stream := func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + return vm.smtpOpenMessageStream(obj(self), args, blk) + } + d("open_message_stream", stream) + d("ready", stream) + + // #data(msgstr=nil) [{ |stream| }]. + d("data", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := obj(self) + o.requireStarted() + o.deadline() + var msg string + switch { + case len(args) >= 1 && !object.IsNil(args[0]): + msg = smtpMessageString(args[0]) + case blk != nil: + st := vm.newSMTPStream() + vm.callBlock(blk, []object.Value{st}) + msg = st.buf.String() + default: + raise("ArgumentError", "message or block required") + } + res, err := o.sess.Data(msg) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) + }) + + // Low-level commands, each returning a Net::SMTP::Response. + d("ehlo", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.Ehlo(smtpArgStr(args, 0)) + }) + }) + d("helo", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.Helo(smtpArgStr(args, 0)) + }) + }) + d("mailfrom", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.MailFrom(smtpArgStr(args, 0)) + }) + }) + d("rcptto", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.RcptTo(smtpArgStr(args, 0)) + }) + }) + d("rset", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.smtpCmd(obj(self), func(s *netsmtp.Session) (*netsmtp.Response, error) { + return s.Rset() + }) + }) + + // #rcptto_list(to_addrs) sends RCPT for each recipient and returns them. + d("rcptto_list", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + o := obj(self) + o.requireStarted() + to := smtpRecipients(args) + if len(to) == 0 { + raise("ArgumentError", "mail destination not given") + } + for _, t := range to { + o.deadline() + if _, err := o.sess.RcptTo(strArg(t)); err != nil { + smtpRaiseErr(err) + } + } + if blk != nil { + return vm.callBlock(blk, nil) + } + return object.NewArrayFromSlice(to) + }) + + // #authenticate(user, secret, authtype=nil). + d("authenticate", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + o := obj(self) + o.requireStarted() + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2..3)", len(args)) + } + authtype := object.Value(object.NilV) + if len(args) >= 3 { + authtype = args[2] + } + vm.smtpAuthenticate(o, strArg(args[0]), strArg(args[1]), authtype) + return object.NilV + }) +} + +// smtpCmd runs a single low-level command on a started session and wraps its +// reply, raising the mapped error on failure. +func (vm *VM) smtpCmd(o *SMTPObj, fn func(*netsmtp.Session) (*netsmtp.Response, error)) object.Value { + o.requireStarted() + o.deadline() + res, err := fn(o.sess) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) +} + +// smtpArgStr returns the i-th argument as a String, or "" when absent. +func smtpArgStr(args []object.Value, i int) string { + if i >= len(args) || object.IsNil(args[i]) { + return "" + } + return strArg(args[i]) +} + +// smtpRecipients flattens a recipient argument list: a single Array argument is +// its elements, otherwise the positional arguments themselves. +func smtpRecipients(args []object.Value) []object.Value { + if len(args) == 1 { + if arr, ok := args[0].(*object.Array); ok { + return arr.Elems + } + } + return args +} + +// smtpSendMessage implements Net::SMTP#send_message: MAIL FROM, a RCPT TO per +// recipient, then the DATA payload; it returns the final Net::SMTP::Response. +func (vm *VM) smtpSendMessage(o *SMTPObj, args []object.Value) object.Value { + o.requireStarted() + if len(args) < 2 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2+)", len(args)) + } + msg := smtpMessageString(args[0]) + from := strArg(args[1]) + to := smtpRecipients(args[2:]) + if len(to) == 0 { + raise("ArgumentError", "mail destination not given") + } + o.deadline() + if _, err := o.sess.MailFrom(from); err != nil { + smtpRaiseErr(err) + } + for _, t := range to { + if _, err := o.sess.RcptTo(strArg(t)); err != nil { + smtpRaiseErr(err) + } + } + res, err := o.sess.Data(msg) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) +} + +// smtpOpenMessageStream implements Net::SMTP#open_message_stream: MAIL FROM, a +// RCPT TO per recipient, then a block that appends the message body to a stream +// adapter whose accumulated text is sent as the DATA payload. +func (vm *VM) smtpOpenMessageStream(o *SMTPObj, args []object.Value, blk *Proc) object.Value { + o.requireStarted() + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + if len(args) < 1 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 2)", len(args)) + } + from := strArg(args[0]) + to := smtpRecipients(args[1:]) + if len(to) == 0 { + raise("ArgumentError", "mail destination not given") + } + o.deadline() + if _, err := o.sess.MailFrom(from); err != nil { + smtpRaiseErr(err) + } + for _, t := range to { + if _, err := o.sess.RcptTo(strArg(t)); err != nil { + smtpRaiseErr(err) + } + } + st := vm.newSMTPStream() + vm.callBlock(blk, []object.Value{st}) + res, err := o.sess.Data(st.buf.String()) + if err != nil { + smtpRaiseErr(err) + } + return vm.smtpResponse(res) +} diff --git a/internal/vm/net_smtp_bind.go b/internal/vm/net_smtp_bind.go new file mode 100644 index 00000000..726d043e --- /dev/null +++ b/internal/vm/net_smtp_bind.go @@ -0,0 +1,276 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "strings" + "time" + + netsmtp "github.com/go-ruby-net-smtp/net-smtp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the transport bridge between rbgo's Ruby object graph and the +// interpreter-independent SMTP codec of github.com/go-ruby-net-smtp/net-smtp. The +// command grammar, the DATA dot-stuffing, the reply parser, the EHLO capability +// parse and the SASL auth encodings all live in that library; rbgo supplies the +// one thing it deliberately leaves out — the socket. Net::SMTP dials its own +// TCP/TLS connection, so (unlike the redis/pg bindings' injected-IO seam) the +// transport here is rbgo's real socket layer (socket.go): net_smtp.go's +// Net::SMTP#start opens a TCPSocket to the address/port, wraps it in a smtpConn, +// and hands that to netsmtp.NewSession, which sequences the codec against it +// exactly the way MRI's Net::SMTP drives its @socket. STARTTLS upgrades the same +// socket in place through the crypto/tls handshake the net/http transport already +// uses. Tests drive an in-process SMTP server (net_smtp_internal_test.go) so no +// real network is touched. + +// smtpStartTLS selects the STARTTLS policy for a session, mirroring Net::SMTP's +// @starttls (false / :auto / :always). +const ( + // smtpStartTLSOff never issues STARTTLS. + smtpStartTLSOff = iota + // smtpStartTLSAuto issues STARTTLS only when the EHLO reply advertises it. + smtpStartTLSAuto + // smtpStartTLSAlways always issues STARTTLS (and fails if the server rejects it). + smtpStartTLSAlways +) + +// SMTPObj is the Ruby wrapper around a Net::SMTP session. It owns the socket it +// dials (conn) and the codec driver (sess); both are nil until #start opens them +// and are cleared again by #finish. The configuration fields mirror the Net::SMTP +// accessors (esmtp, tls, starttls, timeouts) and are read at #start time. +type SMTPObj struct { + cls *RClass + address string + port string + + sess *netsmtp.Session + conn *smtpConn + started bool + + esmtp bool // EHLO (true, the default) vs plain HELO + tls bool // implicit TLS on connect (SMTPS) + starttls int // smtpStartTLSOff / Auto / Always + verifyMode int64 + openTO time.Duration + readTO time.Duration +} + +func (o *SMTPObj) ToS() string { return "#" } +func (o *SMTPObj) Inspect() string { return o.ToS() } +func (o *SMTPObj) Truthy() bool { return true } + +// requireStarted raises IOError unless the session is open, matching MRI's guard +// on every command that needs a live connection. +func (o *SMTPObj) requireStarted() { + if !o.started || o.sess == nil { + raise("IOError", "not yet started") + } +} + +// deadline refreshes the socket read deadline before a protocol step when a +// read_timeout is set (0 clears/leaves it unbounded). +func (o *SMTPObj) deadline() { + if o.readTO > 0 && o.conn != nil { + nethttpSetDeadline(o.conn.stream, o.readTO) + } +} + +// closeConn closes the underlying socket, ignoring a close error on an already +// broken connection. +func (o *SMTPObj) closeConn() { + if o.conn != nil { + o.conn.close() + } +} + +// capableStartTLS reports whether the last EHLO advertised STARTTLS +// (Net::SMTP#capable_starttls?). +func (o *SMTPObj) capableStartTLS() bool { + if o.sess == nil { + return false + } + _, ok := o.sess.Capabilities()["STARTTLS"] + return ok +} + +// capableAuth reports whether the last EHLO advertised the given SASL mechanism +// (backs Net::SMTP#capable_plain_auth? / #capable_login_auth? / #capable_cram_md5_auth?). +func (o *SMTPObj) capableAuth(mech string) bool { + if o.sess == nil { + return false + } + auths, ok := o.sess.Capabilities()["AUTH"] + if !ok { + return false + } + for _, a := range auths { + if strings.EqualFold(a, mech) { + return true + } + } + return false +} + +// smtpConn adapts rbgo's socket transport (a streamIO — a raw tcpSocket or a TLS +// sslSocket) to the netsmtp.Conn seam the codec drives: WriteLine appends CRLF to +// a request line, ReadLine reads one reply line and chops its CRLF, WriteRaw +// writes an already dot-stuffed DATA payload verbatim, and StartTLS upgrades the +// socket in place through the TLS handshake. +type smtpConn struct { + stream streamIO + host string + verifyMode int64 +} + +// WriteLine sends a request line followed by CRLF (Net::BufferedIO#writeline). +func (c *smtpConn) WriteLine(line string) error { + _, err := c.stream.writer().Write([]byte(line + netsmtp.CRLF)) + return err +} + +// ReadLine reads one reply line and returns it with the trailing CRLF removed +// (Net::BufferedIO#readline). +func (c *smtpConn) ReadLine() (string, error) { + line, err := c.stream.reader().ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +// WriteRaw writes the given bytes to the socket unchanged (the dot-stuffed, +// terminated DATA payload). +func (c *smtpConn) WriteRaw(b string) error { + _, err := c.stream.writer().Write([]byte(b)) + return err +} + +// StartTLS upgrades the connection to TLS in place, replacing the raw stream with +// a TLS one over the same net.Conn (Net::SMTP#starttls). verifyMode 0 +// (VERIFY_NONE) skips certificate verification, matching the transport default. +func (c *smtpConn) StartTLS() error { + conn := nethttpNetConn(c.stream) + if conn == nil { + return &netsmtp.SMTPError{Kind: netsmtp.KindUnknown, Msg: "connection cannot be upgraded to TLS"} + } + s, err := nethttpTLSWrap(conn, c.host, c.verifyMode) + if err != nil { + return err + } + c.stream = s + return nil +} + +// close closes the underlying socket. +func (c *smtpConn) close() error { return c.stream.closeConn() } + +// smtpDial opens the socket for a session and installs the codec driver: a raw +// TCP dial (with the open_timeout, if any), optionally wrapped in TLS straight +// away for an implicit-TLS (SMTPS) session. +func (vm *VM) smtpDial(o *SMTPObj) error { + conn, err := nethttpRawDial(o.address, o.port, o.openTO) + if err != nil { + return err + } + var stream streamIO + if o.tls { + s, terr := nethttpTLSWrap(conn, o.address, o.verifyMode) + if terr != nil { + return terr + } + stream = s + } else { + stream = newTCPSocket(nil, conn) + } + o.conn = &smtpConn{stream: stream, host: o.address, verifyMode: o.verifyMode} + o.sess = netsmtp.NewSession(o.conn) + return nil +} + +// smtpRaiseErr raises the Ruby exception a codec/transport error maps to: a +// *netsmtp.SMTPError becomes its kind's Net::SMTP* class (the exact class MRI's +// check_response would raise), any other (transport) error becomes IOError. +func smtpRaiseErr(err error) { + if se, ok := err.(*netsmtp.SMTPError); ok { + raise(se.Kind.RubyClass(), "%s", se.Error()) + } + raise("IOError", "%s", err.Error()) +} + +// smtpRespErr builds the SMTPError a non-success reply would raise, using the +// reply's own exception_class (the lib's errorFor is unexported, but Response and +// SMTPError expose everything needed to reproduce it). +func smtpRespErr(res *netsmtp.Response) *netsmtp.SMTPError { + return &netsmtp.SMTPError{Kind: res.ExceptionClass(), Response: res} +} + +// smtpStr renders a value the way an IO write coerces it: a String yields its +// bytes, anything else its to_s. +func smtpStr(v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + return v.ToS() +} + +// smtpMessageString coerces a send_message / data payload to its wire string: a +// String verbatim, an Array joined (each element to_s), anything else its to_s. +func smtpMessageString(v object.Value) string { + switch n := v.(type) { + case *object.String: + return n.Str() + case *object.Array: + var b strings.Builder + for _, e := range n.Elems { + b.WriteString(smtpStr(e)) + } + return b.String() + } + return v.ToS() +} + +// smtpKw reads a keyword value from a trailing options Hash by Symbol or String +// key, mirroring the redis/pg keyword lookup. +func smtpKw(h *object.Hash, name string) (object.Value, bool) { + if v, ok := h.Get(object.Symbol(name)); ok { + return v, true + } + return h.Get(object.NewString(name)) +} + +// smtpStartTLSMode maps a starttls: keyword to a policy: :always / true → Always, +// :auto → Auto, false / nil → Off. +func smtpStartTLSMode(v object.Value) int { + switch n := v.(type) { + case object.Symbol: + switch string(n) { + case "always": + return smtpStartTLSAlways + case "auto": + return smtpStartTLSAuto + } + return smtpStartTLSOff + case object.Bool: + if bool(n) { + return smtpStartTLSAlways + } + return smtpStartTLSOff + } + if v.Truthy() { + return smtpStartTLSAlways + } + return smtpStartTLSOff +} + +// smtpAuthTypeName normalises an authtype (Symbol or String) to the codec's +// mechanism key: :plain / "PLAIN" → "plain", :login → "login", :cram_md5 / +// "CRAM-MD5" → "cram_md5". +func smtpAuthTypeName(v object.Value) string { + s := smtpStr(v) + s = strings.ToLower(strings.ReplaceAll(s, "-", "_")) + return s +} diff --git a/internal/vm/net_smtp_internal_test.go b/internal/vm/net_smtp_internal_test.go new file mode 100644 index 00000000..2707f873 --- /dev/null +++ b/internal/vm/net_smtp_internal_test.go @@ -0,0 +1,1054 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" + + netsmtp "github.com/go-ruby-net-smtp/net-smtp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// --- in-process SMTP server ------------------------------------------------ + +// Canned reply fragments the scripted server sends. Multi-line EHLO replies use +// the "250-…/250 …" continuation form the codec's Capabilities parse expects. +const ( + smtpGreet = "220 test ESMTP\r\n" + smtpEHLO = "250-test\r\n250 SIZE 1000\r\n" // no AUTH, no STARTTLS + smtpEHLOAuth = "250-test\r\n250-SIZE 1000\r\n250 AUTH PLAIN LOGIN CRAM-MD5\r\n" + smtpOK = "250 2.0.0 OK\r\n" + smtpDataReady = "354 Start mail input\r\n" + smtpQueued = "250 2.0.0 queued\r\n" + smtpBye = "221 2.0.0 Bye\r\n" +) + +// smtpDrive runs the request/response half of a session: for each client command +// line it sends the next scripted reply; a "354" reply enters DATA mode, draining +// client lines until a lone "." and then sending the following (final) reply. +func smtpDrive(r *bufio.Reader, w func(string), responses []string, idx int) { + for idx < len(responses) { + if _, err := r.ReadString('\n'); err != nil { + return + } + resp := responses[idx] + idx++ + w(resp) + if strings.HasPrefix(resp, "354") { + for { + dl, err := r.ReadString('\n') + if err != nil { + return + } + if strings.TrimRight(dl, "\r\n") == "." { + break + } + } + if idx < len(responses) { + w(responses[idx]) + idx++ + } + } + } +} + +// smtpDialog serves a full plaintext session: the greeting (responses[0]) then the +// command/response exchange. +func smtpDialog(conn net.Conn, responses []string) { + if len(responses) == 0 { + return + } + r := bufio.NewReader(conn) + w := func(s string) { conn.Write([]byte(s)) } + w(responses[0]) + smtpDrive(r, w, responses, 1) +} + +// smtpServer starts a one-shot scripted plaintext SMTP server on 127.0.0.1 and +// returns its host and port. The accepted connection is closed on test cleanup, +// which unblocks the server goroutine no matter how the client left the session +// (so a mid-session error in the script never leaks a goroutine). +func smtpServer(t *testing.T, responses []string) (host, port string) { + t.Helper() + return smtpServeFunc(t, func(conn net.Conn) { smtpDialog(conn, responses) }) +} + +// smtpServeFunc starts a server that serves every accepted connection with the +// same handler (so a test may open several sessions against it), and wires the +// cleanup that stops the listener and closes any still-open connection — which +// unblocks a handler no matter how the client left the session, so a mid-session +// error in the script never leaks a goroutine. +func smtpServeFunc(t *testing.T, serve func(net.Conn)) (host, port string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + var mu sync.Mutex + var conns []net.Conn + var wg sync.WaitGroup + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + break + } + mu.Lock() + conns = append(conns, conn) + mu.Unlock() + wg.Add(1) + go func(c net.Conn) { + defer wg.Done() + defer c.Close() + serve(c) + }(conn) + } + wg.Wait() + }() + t.Cleanup(func() { + ln.Close() + mu.Lock() + for _, c := range conns { + c.Close() + } + mu.Unlock() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("smtp test server did not stop") + } + }) + h, p, _ := net.SplitHostPort(ln.Addr().String()) + return h, p +} + +// smtpTestCert generates a self-signed P-256 certificate for 127.0.0.1 (the client +// dials with VERIFY_NONE, so any valid cert is accepted). +func smtpTestCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("cert: %v", err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +// STARTTLS server modes. +const ( + smtpTLSUpgrade = iota // 220 then a real TLS handshake, then afterTLS over TLS + smtpTLSReject // 502 to STARTTLS (no upgrade) + smtpTLSHandshake // 220 then close before the handshake (handshake fails) +) + +// smtpStartTLSServer serves the STARTTLS prologue in plaintext (greeting, EHLO → +// plainEHLO, STARTTLS → per mode) and, on a successful upgrade, the afterTLS +// exchange over TLS. +func smtpStartTLSServer(t *testing.T, plainEHLO string, afterTLS []string, mode int) (host, port string) { + t.Helper() + cert := smtpTestCert(t) + return smtpServeFunc(t, func(conn net.Conn) { + r := bufio.NewReader(conn) + w := func(s string) { conn.Write([]byte(s)) } + w(smtpGreet) + if _, err := r.ReadString('\n'); err != nil { // EHLO + return + } + w(plainEHLO) + if _, err := r.ReadString('\n'); err != nil { // STARTTLS + return + } + switch mode { + case smtpTLSReject: + w("502 5.5.1 STARTTLS not available\r\n") + return + case smtpTLSHandshake: + w("220 2.0.0 Ready to start TLS\r\n") + return // drop the connection before the handshake + } + w("220 2.0.0 Ready to start TLS\r\n") + tconn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + if err := tconn.Handshake(); err != nil { + return + } + smtpDrive(bufio.NewReader(tconn), func(s string) { tconn.Write([]byte(s)) }, afterTLS, 0) + }) +} + +// smtpImplicitTLSServer upgrades to TLS immediately on connect (SMTPS) and then +// serves the plaintext-style dialog over the TLS connection. +func smtpImplicitTLSServer(t *testing.T, responses []string) (host, port string) { + t.Helper() + cert := smtpTestCert(t) + return smtpServeFunc(t, func(conn net.Conn) { + tconn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + if err := tconn.Handshake(); err != nil { + return + } + smtpDialog(tconn, responses) + }) +} + +// smtpScript substitutes the server host/port into a Ruby template. +func smtpScript(host, port, tmpl string) string { + return strings.ReplaceAll(strings.ReplaceAll(tmpl, "HOST", host), "PORT", port) +} + +// --- require, error tree, ports -------------------------------------------- + +func TestNetSMTPLoadable(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "net/smtp"`, "true\n"}, + {`require "net/smtp"; p require "net/smtp"`, "false\n"}, + {`require "net/smtp"; p Net::SMTP.is_a?(Class)`, "true\n"}, + {`require "net/smtp"; p Net::ProtocolError < StandardError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPError < Net::ProtocolError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPUnknownError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPServerBusy < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPSyntaxError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPAuthenticationError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPFatalError < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTPUnsupportedCommand < Net::SMTPError`, "true\n"}, + {`require "net/smtp"; p Net::SMTP.default_port`, "25\n"}, + {`require "net/smtp"; p Net::SMTP.default_tls_port`, "465\n"}, + {`require "net/smtp"; p Net::SMTP.default_ssl_port`, "465\n"}, + {`require "net/smtp"; p Net::SMTP.default_submission_port`, "587\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +// --- send_message / sendmail / response ------------------------------------ + +func TestNetSMTPSendMessage(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + r = smtp.send_message("From: a\r\nTo: b\r\n\r\nhi\r\n", "a@x.test", "b@y.test") + puts r.status + puts r.success? + puts r.continue? + puts r.message + puts smtp.started? +end`)) + want := "250\ntrue\nfalse\n250 2.0.0 queued\ntrue\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNetSMTPSendmailAliasAndArrayRcpt(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +r = smtp.sendmail("body\r\n", "a@x.test", ["b@y.test", "c@z.test"]) +puts r.status +smtp.finish +puts smtp.started?`)) + if got != "250\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPOpenMessageStream(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + r = smtp.open_message_stream("a@x.test", ["b@y.test"]) do |f| + f.puts "From: a" + f.print "Sub" + f.puts "ject: hi" + f << "\r\n" + n = f.write("body\r\n") + f.puts + end + puts r.status +end`)) + if got != "250\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPReadyAlias(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + r = smtp.ready("a@x.test", "b@y.test") { |f| f.write "hello\r\n" } + puts r.status +end`)) + if got != "250\n" { + t.Errorf("got=%q", got) + } +} + +// --- data (msg + block) + low-level commands ------------------------------- + +func TestNetSMTPDataAndLowLevel(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, + smtpOK, // mailfrom + smtpOK, // rcptto + smtpOK, // rset + "250-test\r\n250 OK\r\n", // ehlo (low-level) + "250 hello\r\n", // helo (low-level) + smtpDataReady, smtpQueued, // data(msg) + smtpDataReady, smtpQueued, // data { block } + smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.mailfrom("a@x.test").status + puts smtp.rcptto("b@y.test").status + puts smtp.rset.status + puts smtp.ehlo("me.test").status + puts smtp.helo("me.test").status + puts smtp.data("one\r\n").status + puts smtp.data { |f| f.puts "two" }.status +end`)) + want := "250\n250\n250\n250\n250\n250\n250\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNetSMTPRcpttoList(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpOK, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + a = smtp.rcptto_list(["b@y.test", "c@z.test"]) + puts a.length + puts(smtp.rcptto_list(["d@w.test"]) { "blockval" }) +end`)) + if got != "2\nblockval\n" { + t.Errorf("got=%q", got) + } +} + +// --- authentication -------------------------------------------------------- + +func TestNetSMTPAuthMechanisms(t *testing.T) { + cases := []struct { + name string + replies []string + call string + }{ + {"plain", []string{smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye}, + `smtp.authenticate("u", "p", :plain)`}, + {"login", []string{smtpGreet, smtpEHLOAuth, "334 VXNlcm5hbWU6\r\n", "334 UGFzc3dvcmQ6\r\n", "235 2.7.0 OK\r\n", smtpBye}, + `smtp.authenticate("u", "p", :login)`}, + {"cram", []string{smtpGreet, smtpEHLOAuth, "334 aGVsbG8=\r\n", "235 2.7.0 OK\r\n", smtpBye}, + `smtp.authenticate("u", "p", "CRAM-MD5")`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + host, port := smtpServer(t, c.replies) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + `+c.call+` + puts "ok" +end`)) + if got != "ok\n" { + t.Errorf("%s: got=%q", c.name, got) + } + }) + } +} + +func TestNetSMTPAuthOnStart(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, "localhost", "user", "secret", :plain) do |smtp| + puts smtp.started? +end`)) + if got != "true\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPAuthKeywordAndStringKeys(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye, + }) + // String-keyed options exercise the smtpKw String fallback and the password: + // alias for secret. + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, {"helo" => "me", "user" => "u", "password" => "p", "authtype" => :plain, "starttls" => false}) do |smtp| + puts smtp.started? +end`)) + if got != "true\n" { + t.Errorf("got=%q", got) + } +} + +// --- capability predicates ------------------------------------------------- + +func TestNetSMTPCapabilities(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.capable_plain_auth? + puts smtp.capable_login_auth? + puts smtp.capable_cram_md5_auth? + puts smtp.capable_starttls? + puts smtp.capable_auth_types.join(",") +end`)) + want := "true\ntrue\ntrue\nfalse\nPLAIN,LOGIN,CRAM-MD5\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNetSMTPCapabilitiesAuthNoMatch(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "250-test\r\n250 AUTH PLAIN\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.capable_plain_auth? + puts smtp.capable_cram_md5_auth? +end`)) + if got != "true\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPCapabilitiesBeforeStart(t *testing.T) { + got := eval(t, ` +require "net/smtp" +smtp = Net::SMTP.new("mail.test", 25) +puts smtp.capable_starttls? +puts smtp.capable_plain_auth? +puts smtp.capable_login_auth? +puts smtp.capable_auth_types.length +puts smtp.address +puts smtp.port`) + if got != "false\nfalse\nfalse\n0\nmail.test\n25\n" { + t.Errorf("got=%q", got) + } +} + +// --- ESMTP fallback and esmtp toggle --------------------------------------- + +func TestNetSMTPEsmtpFallback(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, "502 5.5.1 EHLO not implemented\r\n", "250 hello\r\n", smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.esmtp? +end`)) + if got != "false\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPEsmtpDisabled(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "250 hello\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +puts smtp.esmtp +smtp.esmtp = false +smtp.start do |s| + puts s.esmtp? +end`)) + if got != "true\nfalse\n" { + t.Errorf("got=%q", got) + } +} + +// --- TLS: STARTTLS auto/always, implicit, exclusivity, errors -------------- + +func TestNetSMTPStartTLSAuto(t *testing.T) { + after := []string{"250-test\r\n250 OK\r\n", smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye} + host, port := smtpStartTLSServer(t, "250-test\r\n250 STARTTLS\r\n", after, smtpTLSUpgrade) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.started? + puts smtp.send_message("hi\r\n", "a@x.test", "b@y.test").status +end`)) + if got != "true\n250\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPStartTLSAlways(t *testing.T) { + after := []string{"250 test\r\n", smtpBye} + host, port := smtpStartTLSServer(t, smtpEHLO, after, smtpTLSUpgrade) // EHLO does not advertise STARTTLS + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +puts smtp.starttls_auto? +smtp.enable_starttls +puts smtp.starttls? +puts smtp.starttls_always? +smtp.start { |s| puts s.started? }`)) + if got != "true\ntrue\ntrue\ntrue\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPStartTLSReject(t *testing.T) { + host, port := smtpStartTLSServer(t, smtpEHLO, nil, smtpTLSReject) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.enable_starttls +smtp.start`)) + if class != "Net::SMTPSyntaxError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPStartTLSHandshakeFailure(t *testing.T) { + host, port := smtpStartTLSServer(t, smtpEHLO, nil, smtpTLSHandshake) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.enable_starttls +smtp.start`)) + if class != "IOError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPImplicitTLS(t *testing.T) { + host, port := smtpImplicitTLSServer(t, []string{ + smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, tls: true) do |smtp| + puts smtp.tls? + puts smtp.ssl? + puts smtp.send_message("hi\r\n", "a@x.test", "b@y.test").status +end`)) + if got != "true\ntrue\n250\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPImplicitTLSHandshakeFailure(t *testing.T) { + // Implicit TLS against a plaintext server: the TLS wrap in smtpDial fails. + host, port := smtpServer(t, []string{smtpGreet}) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, tls: true) { |s| }`)) + if class != "SocketError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPTLSExclusivity(t *testing.T) { + got := eval(t, ` +require "net/smtp" +def excl + yield + "no-error" +rescue ArgumentError => e + e.message +end +smtp = Net::SMTP.new("mail.test") +puts(excl { smtp.enable_starttls; smtp.enable_tls }) +smtp2 = Net::SMTP.new("mail.test") +puts(excl { smtp2.enable_tls; smtp2.enable_starttls }) +smtp3 = Net::SMTP.new("mail.test") +puts(excl { smtp3.enable_tls; smtp3.enable_starttls_auto }) +smtp4 = Net::SMTP.new("mail.test") +smtp4.enable_ssl +puts smtp4.ssl? +smtp4.disable_tls +puts smtp4.tls? +smtp5 = Net::SMTP.new("mail.test") +smtp5.enable_starttls_auto +smtp5.disable_starttls +puts smtp5.starttls? +smtp5.disable_ssl +puts smtp5.ssl?`) + want := "SMTPS and STARTTLS is exclusive\n" + + "SMTPS and STARTTLS is exclusive\n" + + "SMTPS and STARTTLS is exclusive\n" + + "true\nfalse\nfalse\nfalse\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// --- timeouts -------------------------------------------------------------- + +func TestNetSMTPTimeouts(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, smtpOK, smtpOK, smtpDataReady, smtpQueued, smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +puts smtp.open_timeout.inspect +puts smtp.read_timeout.inspect +smtp.open_timeout = 5 +smtp.read_timeout = 2.5 +puts smtp.open_timeout +puts smtp.read_timeout +smtp.start do |s| + puts s.send_message("hi\r\n", "a@x.test", "b@y.test").status +end`)) + want := "nil\nnil\n5\n2.5\n250\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// --- runtime error paths --------------------------------------------------- + +func TestNetSMTPGreetingErrors(t *testing.T) { + // Non-2xx greeting → the reply's exception class. + host, port := smtpServer(t, []string{"554 5.3.2 no service\r\n"}) + if class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "Net::SMTPFatalError" { + t.Errorf("greeting class=%q", class) + } + // Server closes before greeting → transport IOError. + host2, port2 := smtpServer(t, []string{}) + if class, _ := evalErr(t, smtpScript(host2, port2, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "IOError" { + t.Errorf("no-greeting class=%q", class) + } +} + +func TestNetSMTPEhloTransportError(t *testing.T) { + // Greeting then immediate close: EHLO read hits EOF (a non-SMTP error). + host, port := smtpServer(t, []string{smtpGreet}) + if class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "IOError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPHeloFallbackBothFail(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "502 no ehlo\r\n", "550 no helo\r\n"}) + if class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)); class != "Net::SMTPFatalError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPHeloDisabledError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, "550 no helo\r\n"}) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.esmtp = false +smtp.start`)) + if class != "Net::SMTPFatalError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPMailfromError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, "550 5.1.0 bad sender\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + smtp.send_message("hi\r\n", "a@x.test", "b@y.test") +rescue Net::SMTPFatalError => e + puts e.class +end +smtp.finish`)) + if got != "Net::SMTPFatalError\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPDataContinueError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, "550 no data\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + smtp.data("hi\r\n") +rescue Net::SMTPUnknownError => e + puts e.class +end +smtp.finish`)) + if got != "Net::SMTPUnknownError\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPAuthFailure(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, "535 5.7.8 bad creds\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + smtp.authenticate("u", "p", :plain) +rescue Net::SMTPAuthenticationError => e + puts e.class +end +smtp.finish`)) + if got != "Net::SMTPAuthenticationError\n" { + t.Errorf("got=%q", got) + } +} + +func TestNetSMTPUnknownAuthType(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, smtpBye}) + class, msg := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) { |s| s.authenticate("u", "p", :bogus) }`)) + if class != "ArgumentError" || !strings.Contains(msg, "unknown auth type") { + t.Errorf("class=%q msg=%q", class, msg) + } +} + +func TestNetSMTPDialError(t *testing.T) { + // Bind then release a port so nothing is listening. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() + host, port, _ := net.SplitHostPort(addr) + class, _ := evalErr(t, smtpScript(host, port, `require "net/smtp"; Net::SMTP.start("HOST", PORT) { |s| }`)) + if class != "SocketError" { + t.Errorf("class=%q", class) + } +} + +func TestNetSMTPStartTwice(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, smtpBye}) + class, _ := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) { |s| s.start }`)) + if class != "IOError" { + t.Errorf("class=%q", class) + } +} + +// --- argument / guard errors (no connection needed) ------------------------ + +func TestNetSMTPArgumentErrors(t *testing.T) { + cases := []struct{ src, class, msgHas string }{ + {`require "net/smtp"; Net::SMTP.new`, "ArgumentError", "expected 1..2"}, + {`require "net/smtp"; Net::SMTP.start`, "ArgumentError", "expected 1+"}, + {`require "net/smtp"; Net::SMTP.new("h").mailfrom("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").rcptto("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").rset`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").ehlo("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").helo("a")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").data("x")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").send_message("m","f","t")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").open_message_stream("f","t"){}`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").rcptto_list(["t"])`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").authenticate("u","p")`, "IOError", "not yet started"}, + {`require "net/smtp"; Net::SMTP.new("h").finish`, "IOError", "not yet started"}, + } + for _, c := range cases { + class, msg := evalErr(t, c.src) + if class != c.class || !strings.Contains(msg, c.msgHas) { + t.Errorf("src=%q class=%q msg=%q want class=%q has=%q", c.src, class, msg, c.class, c.msgHas) + } + } +} + +func TestNetSMTPStartedGuardArgErrors(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, smtpBye}) + cases := []struct{ call, class, has string }{ + {`smtp.send_message("only")`, "ArgumentError", "expected 2+"}, + {`smtp.send_message("m", "from")`, "ArgumentError", "mail destination"}, + {`smtp.open_message_stream("from")`, "LocalJumpError", "no block"}, + {`smtp.open_message_stream { |f| }`, "ArgumentError", "expected 2"}, + {`smtp.open_message_stream("from") { |f| }`, "ArgumentError", "mail destination"}, + {`smtp.rcptto_list([])`, "ArgumentError", "mail destination"}, + {`smtp.authenticate("u")`, "ArgumentError", "expected 2..3"}, + {`smtp.data`, "ArgumentError", "message or block"}, + } + for _, c := range cases { + class, msg := evalErr(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +`+c.call)) + if class != c.class || !strings.Contains(msg, c.has) { + t.Errorf("call=%q class=%q msg=%q want=%q/%q", c.call, class, msg, c.class, c.has) + } + } +} + +// --- Go-level unit tests for branches not reachable from Ruby -------------- + +// TestNetSMTPObjectStrings exercises to_s / inspect / truthiness on the three +// value types and the Response#string reader. +func TestNetSMTPObjectStrings(t *testing.T) { + host, port := smtpServer(t, []string{ + smtpGreet, smtpEHLO, + smtpOK, smtpOK, smtpDataReady, smtpQueued, // send_message + smtpOK, smtpOK, smtpDataReady, smtpQueued, // open_message_stream + smtpBye, + }) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.to_s.include?("Net::SMTP") + puts smtp.inspect.include?("Net::SMTP") + puts(smtp ? "t" : "f") + r = smtp.send_message("x\r\n", "a@x.test", "b@y.test") + puts r.to_s + puts r.inspect.include?("Response") + puts r.string.length > 0 + puts(r ? "t" : "f") + smtp.open_message_stream("a@x.test", "b@y.test") do |g| + puts g.to_s.include?("Adapter") + puts g.inspect.include?("Adapter") + puts(g ? "t" : "f") + g.write "y\r\n" + end +end`)) + want := "true\ntrue\nt\n250 2.0.0 queued\ntrue\ntrue\nt\ntrue\ntrue\nt\n" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +// TestNetSMTPSecretKeywordAndDefaultAuth covers the secret: keyword, a two-arg +// authenticate (default :plain), and capable_plain_auth? with an AUTH-less EHLO. +func TestNetSMTPSecretKeywordAndDefaultAuth(t *testing.T) { + h1, p1 := smtpServer(t, []string{smtpGreet, smtpEHLOAuth, "235 2.7.0 OK\r\n", smtpBye}) + if got := eval(t, smtpScript(h1, p1, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT, user: "u", secret: "p", authtype: :plain) { |s| puts s.started? }`)); got != "true\n" { + t.Errorf("secret kw: got=%q", got) + } + + h2, p2 := smtpServer(t, []string{smtpGreet, smtpEHLO, "235 2.7.0 OK\r\n", smtpBye}) + if got := eval(t, smtpScript(h2, p2, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.capable_plain_auth? + smtp.authenticate("u", "p") + puts "authed" +end`)); got != "false\nauthed\n" { + t.Errorf("default auth: got=%q", got) + } +} + +// TestNetSMTPNoArgEhloAndLowLevelError covers a no-argument ehlo (empty domain) +// and a low-level command failure (smtpCmd's error path). +func TestNetSMTPNoArgEhloAndLowLevelError(t *testing.T) { + host, port := smtpServer(t, []string{smtpGreet, smtpEHLO, "250 ok\r\n", "550 5.1.0 bad\r\n", smtpBye}) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +Net::SMTP.start("HOST", PORT) do |smtp| + puts smtp.ehlo.status + begin + smtp.mailfrom("nope") + rescue Net::SMTPFatalError => e + puts e.class + end +end`)) + if got != "250\nNet::SMTPFatalError\n" { + t.Errorf("got=%q", got) + } +} + +// TestNetSMTPSendAndStreamErrors covers the RCPT/DATA failure branches of +// send_message and open_message_stream. +func TestNetSMTPSendAndStreamErrors(t *testing.T) { + cases := []struct { + name string + responses []string + call string + wantClass string + }{ + {"send_rcpt", []string{smtpGreet, smtpEHLO, smtpOK, "550 bad rcpt\r\n", smtpBye}, + `smtp.send_message("m\r\n", "a@x.test", "b@y.test")`, "Net::SMTPFatalError"}, + {"send_data", []string{smtpGreet, smtpEHLO, smtpOK, smtpOK, "550 no data\r\n", smtpBye}, + `smtp.send_message("m\r\n", "a@x.test", "b@y.test")`, "Net::SMTPUnknownError"}, + {"stream_mail", []string{smtpGreet, smtpEHLO, "550 bad from\r\n", smtpBye}, + `smtp.open_message_stream("a@x.test", "b@y.test") { |f| f.write "x" }`, "Net::SMTPFatalError"}, + {"stream_rcpt", []string{smtpGreet, smtpEHLO, smtpOK, "550 bad rcpt\r\n", smtpBye}, + `smtp.open_message_stream("a@x.test", "b@y.test") { |f| f.write "x" }`, "Net::SMTPFatalError"}, + {"stream_data", []string{smtpGreet, smtpEHLO, smtpOK, smtpOK, "550 no data\r\n", smtpBye}, + `smtp.open_message_stream("a@x.test", "b@y.test") { |f| f.write "x" }`, "Net::SMTPUnknownError"}, + {"rcptto_list", []string{smtpGreet, smtpEHLO, "550 bad rcpt\r\n", smtpBye}, + `smtp.rcptto_list(["b@y.test"])`, "Net::SMTPFatalError"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + host, port := smtpServer(t, c.responses) + got := eval(t, smtpScript(host, port, ` +require "net/smtp" +smtp = Net::SMTP.new("HOST", PORT) +smtp.start +begin + `+c.call+` +rescue Net::SMTPError => e + puts e.class +end +smtp.finish`)) + if got != c.wantClass+"\n" { + t.Errorf("%s: got=%q want=%q", c.name, got, c.wantClass) + } + }) + } +} + +func TestNetSMTPUnitHelpers(t *testing.T) { + // smtpStartTLSMode across all argument shapes. + if smtpStartTLSMode(object.Symbol("always")) != smtpStartTLSAlways { + t.Error("symbol always") + } + if smtpStartTLSMode(object.Symbol("auto")) != smtpStartTLSAuto { + t.Error("symbol auto") + } + if smtpStartTLSMode(object.Symbol("nope")) != smtpStartTLSOff { + t.Error("symbol other") + } + if smtpStartTLSMode(object.Bool(true)) != smtpStartTLSAlways { + t.Error("bool true") + } + if smtpStartTLSMode(object.Bool(false)) != smtpStartTLSOff { + t.Error("bool false") + } + if smtpStartTLSMode(object.NewString("x")) != smtpStartTLSAlways { + t.Error("truthy other") + } + if smtpStartTLSMode(object.NilV) != smtpStartTLSOff { + t.Error("nil other") + } + + // smtpAuthTypeName normalisation. + if got := smtpAuthTypeName(object.Symbol("CRAM_MD5")); got != "cram_md5" { + t.Errorf("authtype=%q", got) + } + if got := smtpAuthTypeName(object.NewString("Cram-MD5")); got != "cram_md5" { + t.Errorf("authtype str=%q", got) + } + + // smtpStr and smtpMessageString across value shapes. + if smtpStr(object.NewString("a")) != "a" || smtpStr(object.IntValue(7)) != "7" { + t.Error("smtpStr") + } + if smtpMessageString(object.NewString("x")) != "x" { + t.Error("msg string") + } + if smtpMessageString(object.NewArrayFromSlice([]object.Value{object.NewString("a"), object.IntValue(1)})) != "a1" { + t.Error("msg array") + } + if smtpMessageString(object.IntValue(9)) != "9" { + t.Error("msg other") + } + + // smtpDurationValue across ranges. + if smtpDurationValue(0) != object.NilV { + t.Error("dur zero") + } + if v := smtpDurationValue(5 * time.Second); v != object.IntValue(5) { + t.Errorf("dur int=%v", v) + } + if v := smtpDurationValue(2500 * time.Millisecond); v != object.Float(2.5) { + t.Errorf("dur float=%v", v) + } + + // smtpRespErr keys off the reply code. + if e := smtpRespErr(netsmtp.ParseResponse("530 auth\r\n")); e.Kind != netsmtp.KindAuthentication { + t.Errorf("respErr kind=%v", e.Kind) + } +} + +func TestNetSMTPUnitConnAndFinish(t *testing.T) { + // closeConn on a session with no socket, and finish on a nil-session object, + // exercise the nil-guard branches unreachable from a live Ruby session. + (&SMTPObj{}).closeConn() + New(io.Discard).smtpFinishQuiet(&SMTPObj{}) + + // smtpConn.StartTLS with a stream that has no net.Conn cannot upgrade. + c := &smtpConn{stream: &tcpSocket{}} + if err := c.StartTLS(); err == nil { + t.Error("StartTLS on connless stream should fail") + } + + // capableAuth / capableStartTLS on a driverless object. + o := &SMTPObj{} + if o.capableAuth("PLAIN") || o.capableStartTLS() { + t.Error("capability without session should be false") + } + + // smtpConn read/write over a net.Pipe: WriteLine appends CRLF, ReadLine chops + // it, WriteRaw is verbatim; a closed peer surfaces ReadLine's error path. + cli, srv := net.Pipe() + sc := &smtpConn{stream: newTCPSocket(nil, cli), host: "127.0.0.1"} + go func() { + br := bufio.NewReader(srv) + br.ReadString('\n') // consume the written line + srv.Write([]byte("250 OK\r\n")) + srv.Close() + }() + if err := sc.WriteLine("EHLO me"); err != nil { + t.Fatalf("WriteLine: %v", err) + } + line, err := sc.ReadLine() + if err != nil || line != "250 OK" { + t.Fatalf("ReadLine=%q err=%v", line, err) + } + if _, err := sc.ReadLine(); err == nil { + t.Error("ReadLine after close should error") + } + sc.close() +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..cdece6e8 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -982,6 +982,12 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *PGResultObj: return x.cls + case *SMTPObj: + return x.cls + case *SMTPResponseObj: + return x.cls + case *SMTPStreamObj: + return x.cls case *SequelDBObj: return x.cls case *SequelDatasetObj: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..89344d2b 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,7 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, - "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "net/http": true, "net/https": true, "net/smtp": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, "strscan": true, "fiber": true, "objspace": true, "csv": true,