netsmtp

package module
v0.0.0-...-96436a2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 5 Imported by: 0

README

go-ruby-net-smtp/net-smtp

net-smtp — go-ruby-net-smtp

Docs License Go Coverage

A pure-Go (no cgo) reimplementation of the deterministic core of Ruby's Net::SMTP — the SMTP command grammar, the DATA message dot-stuffing, the reply-line parser, the EHLO capability parse, and the SASL auth encodings (PLAIN, LOGIN, CRAM-MD5). It mirrors MRI 4.0.5's net-smtp 0.5.1 byte-for-byte on every string it produces and every reply it parses — without any Ruby runtime.

It is the SMTP backend for go-embedded-ruby, but is a standalone, reusable module with no dependency on the Ruby runtime — a sibling of go-ruby-regexp (the Onigmo engine), go-ruby-erb (the ERB compiler), and go-ruby-yaml (the Psych codec).

What it is — and isn't. Building a request line (MAIL FROM:<a@b>), dot-stuffing a DATA payload, splitting a 250-…/250 … reply into a Response, decoding an EHLO capability list, and encoding an AUTH PLAIN/LOGIN/CRAM-MD5 exchange are all pure compute, so they live here. The TCP connection, the TLS upgrade (STARTTLS), and the blocking read/write of those bytes are the host's job — the host (rbgo) supplies a small Conn seam, and the Session driver sequences the codec against it. This library never opens a socket.

Features

Faithful port of Net::SMTP's protocol layer, validated against the ruby binary on every supported platform:

  • Command buildersHELO / EHLO / MAIL FROM:<> / RCPT TO:<> / DATA / RSET / QUIT / STARTTLS, each returning the exact reqline getok would send, with Net::SMTP::Address-faithful ESMTP-parameter joining and #uniq dedup, and the validate_line bare-CR/LF rejection.
  • DATA dot-stuffing — the precise InternetMessageIO#write_message semantics: every line normalised to CRLF (\n, bare \r, and \r\n all handled), a leading . doubled, an unterminated final line CRLF-terminated, an empty source emitted as a lone CRLF, then the .\r\n terminator.
  • Reply parserNet::SMTP::Response: three-digit Status, full reply String, Success (2xx), Continue (3xx), Message (first line), CRAMMD5Challenge, and the multi-line 250-/250 continuation assembly.
  • EHLO capability parseResponse#capabilities: the verb → args map from the continuation lines.
  • Error-class selectionResponse#exception_class keyed by code: 4xx → SMTPServerBusy, 50x → SMTPSyntaxError, 53x → SMTPAuthenticationError, 5xx → SMTPFatalError, else SMTPUnknownError, surfaced as an ErrorKind the host maps to the Ruby exception class.
  • SASL auth encodingAUTH PLAIN (base64 \0user\0secret), AUTH LOGIN (base64 user / pass), and AUTH CRAM-MD5 (the HMAC-MD5 challenge response, pure given the decoded challenge — the RFC 2195 tim/tanstaaftanstaaf vector is in the suite), with the >64-byte secret pre-hash path.

CGO-free, dependency-free, 100% test coverage, gofmt + go vet clean, and green across the six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x) and three OSes (Linux, macOS, Windows).

Install

go get github.com/go-ruby-net-smtp/net-smtp

Usage

The codec functions are usable standalone:

package main

import (
	"fmt"

	netsmtp "github.com/go-ruby-net-smtp/net-smtp"
)

func main() {
	line, _ := netsmtp.MailFrom("alice@example.com", "SIZE=4096")
	fmt.Printf("%q\n", netsmtp.Wire(line)) // "MAIL FROM:<alice@example.com> SIZE=4096\r\n"

	fmt.Printf("%q\n", netsmtp.WriteMessage(".hi\r\n")) // "..hi\r\n.\r\n"

	res := netsmtp.ParseResponse("250-PIPELINING\n250 AUTH PLAIN LOGIN\n")
	fmt.Println(res.Success(), res.Capabilities()) // true map[AUTH:[PLAIN LOGIN]]

	fmt.Println(netsmtp.AuthPlain("user", "pass")) // AUTH PLAIN AHVzZXIAcGFzcw==
}

The socket / TLS seam

The host wires in transport by implementing Conn; the Session driver then sequences the codec the way Net::SMTP does (validate_line, writeline, recv_response, check_response):

type Conn interface {
	WriteLine(line string) error // send a request line; the impl appends CRLF
	ReadLine() (string, error)   // read one reply line, CRLF stripped
	WriteRaw(b string) error     // send a DATA payload verbatim
	StartTLS() error             // upgrade the connection in place
}

s := netsmtp.NewSession(conn)
s.Ehlo("me.example.com")
s.AuthCramMD5("user", "secret") // drives AUTH CRAM-MD5 over the seam
s.MailFrom("a@b.com")
s.RcptTo("c@d.com")
s.Data("Subject: hi\r\n\r\nbody\r\n")
s.Quit()

go-embedded-ruby's rbgo binds these: the pure codec produces every byte and parses every reply, and rbgo provides the Conn (the TCP/TLS socket Net::SMTP holds in @socket).

API

// Commands — return the bare reqline (validate_line'd); Wire appends CRLF.
func HELO(domain string) (string, error)
func EHLO(domain string) (string, error)
func MailFrom(from string, params ...string) (string, error)
func RcptTo(to string, params ...string) (string, error)
func DATA() (string, error)
func RSET() (string, error)
func QUIT() (string, error)
func STARTTLS() (string, error)
func ValidateLine(line string) error
func Wire(reqline string) string
func NewAddress(addr string, params ...string) *Address

// Message — dot-stuffed, terminated DATA payload.
func WriteMessage(src string) string

// Response — Net::SMTP::Response.
func ParseResponse(str string) *Response
func (r *Response) Success() bool
func (r *Response) Continue() bool
func (r *Response) Message() string
func (r *Response) Capabilities() map[string][]string
func (r *Response) CRAMMD5Challenge() ([]byte, error)
func (r *Response) ExceptionClass() ErrorKind

// Errors — keyed by reply code; RubyClass maps to the host exception.
type ErrorKind int
func (k ErrorKind) RubyClass() string
type SMTPError struct { Kind ErrorKind; Response *Response; Msg string }

// Auth — SASL encodings.
func AuthPlain(user, secret string) string
func AuthLoginInitial() string
func AuthLoginUser(user string) string
func AuthLoginSecret(secret string) string
func AuthCramMD5Initial() string
func AuthCramMD5Response(user, secret string, challenge []byte) string

// Session — the convenience driver over a host-supplied Conn.
type Conn interface { /* … */ }
type Session struct { /* … */ }
func NewSession(conn Conn) *Session

Tests & coverage

The suite pairs deterministic, ruby-free tests (which alone hold coverage at 100%, so the qemu cross-arch and Windows lanes pass the gate) with a differential MRI oracle: command reqlines, dot-stuffed payloads, reply parses (status / success? / continue? / exception_class / capabilities), and the AUTH PLAIN/LOGIN/CRAM-MD5 strings are computed here and compared byte-for-byte against the system ruby -rnet/smtp. The oracle scripts $stdout.binmode so Windows text-mode never pollutes the bytes, and skip themselves where ruby is absent.

COVERPKG=$(go list ./... | paste -sd, -)
go test -race -coverpkg="$COVERPKG" -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1   # 100.0%

License

BSD-3-Clause — see LICENSE. Copyright the go-ruby-net-smtp/net-smtp authors.

Documentation

Overview

Package netsmtp is a pure-Go (no cgo) reimplementation of the deterministic, interpreter-independent core of Ruby's Net::SMTP: the SMTP command grammar, the DATA message dot-stuffing, the reply-line parser, the EHLO capability parse, and the SASL auth encodings (PLAIN, LOGIN, CRAM-MD5). It mirrors MRI 4.0.5's net-smtp 0.5.1 byte-for-byte on every string it produces and every reply it parses, without any Ruby runtime.

What it is — and isn't

Building a request line ("MAIL FROM:<a@b>"), dot-stuffing a DATA payload, splitting a "250-…/250 …" reply into a Response, decoding an EHLO capability list, and encoding an AUTH PLAIN/LOGIN/CRAM-MD5 exchange are all pure compute, so they live here. The TCP connection, the TLS upgrade (STARTTLS), and the blocking read/write of those bytes are the host's job: this package never opens a socket. The host (go-embedded-ruby's rbgo) supplies a Conn, and the Session driver below sequences the codec against it so the same exchange MRI performs over a real socket is reproduced here with the I/O factored out as a seam.

The socket / TLS seam

A host wires in transport by implementing Conn:

type Conn interface {
	WriteLine(line string) error // send a request line + CRLF
	ReadLine() (string, error)   // read one reply line, CRLF stripped
	WriteRaw(b string) error     // send a DATA payload verbatim
	StartTLS() error             // upgrade the connection in place
}

The codec functions (HELO, MailFrom, WriteMessage, ParseResponse, AuthPlain, …) are usable directly without a Conn; Session is the optional convenience driver.

Index

Constants

View Source
const CRLF = "\r\n"

CRLF is the wire line terminator (Net writeline appends "\r\n").

Variables

This section is empty.

Functions

func AuthCramMD5Initial

func AuthCramMD5Initial() string

AuthCramMD5Initial is the first CRAM-MD5 line (AuthCramMD5#auth: continue('AUTH CRAM-MD5')), which prompts the server for its challenge.

func AuthCramMD5Response

func AuthCramMD5Response(user, secret string, challenge []byte) string

AuthCramMD5Response builds the base64 "user hexdigest" response to a decoded CRAM-MD5 challenge, mirroring AuthCramMD5#auth's final finish(...). The caller supplies the already-base64-decoded challenge bytes (the host's seam reads the server's challenge line; Response.CRAMMD5Challenge decodes it).

func AuthLoginInitial

func AuthLoginInitial() string

AuthLoginInitial is the first AUTH LOGIN line (AuthLogin#auth: continue('AUTH LOGIN')).

func AuthLoginSecret

func AuthLoginSecret(secret string) string

AuthLoginSecret is the base64-encoded secret sent after the password prompt (AuthLogin#auth: finish(base64_encode(secret))).

func AuthLoginUser

func AuthLoginUser(user string) string

AuthLoginUser is the base64-encoded user name sent after the username prompt (AuthLogin#auth: continue(base64_encode(user))).

func AuthPlain

func AuthPlain(user, secret string) string

AuthPlain builds the single AUTH PLAIN request line, mirroring AuthPlain#auth: finish('AUTH PLAIN ' + base64_encode("\0user\0secret")).

func DATA

func DATA() (string, error)

DATA builds the "DATA" request line (Net::SMTP#data sends get_response('DATA')).

func EHLO

func EHLO(domain string) (string, error)

EHLO builds the "EHLO <domain>" request line (Net::SMTP#ehlo).

func HELO

func HELO(domain string) (string, error)

HELO builds the "HELO <domain>" request line (Net::SMTP#helo).

func MailFrom

func MailFrom(from string, params ...string) (string, error)

MailFrom builds the "MAIL FROM:<addr>" request line plus any ESMTP parameters, mirroring Net::SMTP#mailfrom: (["MAIL FROM:<addr>"] + params).join(' ').

func QUIT

func QUIT() (string, error)

QUIT builds the "QUIT" request line (Net::SMTP#quit).

func RSET

func RSET() (string, error)

RSET builds the "RSET" request line (Net::SMTP#rset).

func RcptTo

func RcptTo(to string, params ...string) (string, error)

RcptTo builds the "RCPT TO:<addr>" request line plus any ESMTP parameters, mirroring Net::SMTP#rcptto: (["RCPT TO:<addr>"] + params).join(' ').

func STARTTLS

func STARTTLS() (string, error)

STARTTLS builds the "STARTTLS" request line (Net::SMTP#starttls).

func ValidateLine

func ValidateLine(line string) error

ValidateLine mirrors Net::SMTP#validate_line: a request line must not contain a bare CR or LF (RFC 5321). Any "\r" or "\n" is rejected.

func Wire

func Wire(reqline string) string

Wire renders a request line as it goes on the wire: the line followed by CRLF (Net::BufferedIO#writeline appends "\r\n" to the reqline). It does not validate; the command builders already did.

func WriteMessage

func WriteMessage(src string) string

WriteMessage renders a DATA payload exactly as Net::InternetMessageIO#write_message puts it on the wire: every line normalised to CRLF, dot-stuffed (a leading "." doubled), an unterminated final line CRLF-terminated, an empty source emitted as a single CRLF, and the whole closed by the "." + CRLF terminator.

WriteMessage(".hi\r\n") == "..hi\r\n.\r\n"
WriteMessage("")        == "\r\n.\r\n"

Types

type Address

type Address struct {
	Address    string
	Parameters []string
}

Address mirrors Net::SMTP::Address: an envelope address plus its ESMTP parameters. Parameters are deduplicated preserving first-seen order, each rendered "k=v" (or bare "k") just as the Ruby constructor does.

func NewAddress

func NewAddress(addr string, params ...string) *Address

NewAddress builds an Address from a mail address and zero or more "k=v" (or bare) parameter strings, deduplicating while preserving order (Ruby's #uniq).

type Conn

type Conn interface {
	// WriteLine sends a request line; the implementation appends CRLF.
	WriteLine(line string) error
	// ReadLine reads one reply line and returns it with the trailing CRLF
	// removed (Ruby readline = readuntil("\n").chop).
	ReadLine() (string, error)
	// WriteRaw writes the given bytes to the connection unchanged (used for the
	// already dot-stuffed, terminated DATA payload).
	WriteRaw(b string) error
	// StartTLS upgrades the connection to TLS in place.
	StartTLS() error
}

Conn is the transport seam the host supplies. It abstracts the TCP/TLS socket Net::SMTP holds in @socket; the codec drives it but never creates it. WriteLine sends a request line (the impl appends CRLF, or accepts the pre-terminated form from the command builders — implementations append CRLF themselves to mirror BufferedIO#writeline). ReadLine returns one reply line with its CRLF removed (Net::BufferedIO#readline). WriteRaw writes a DATA payload verbatim. StartTLS upgrades the connection in place.

type ErrBareCRLF

type ErrBareCRLF struct{}

ErrBareCRLF is returned by ValidateLine and the command builders when a request line contains a bare CR or LF, mirroring Net::SMTP#validate_line which raises ArgumentError, "A line must not contain CR or LF".

func (ErrBareCRLF) Error

func (ErrBareCRLF) Error() string

type ErrorKind

type ErrorKind int

ErrorKind names a member of the Net::SMTPError family. The host (rbgo) maps each kind to the matching Ruby exception class; the codec selects the kind purely from the reply code so the mapping needs no interpreter.

const (
	// KindUnknown is Net::SMTPUnknownError (code outside 4xx/5xx, or a 5xx not
	// covered by the more specific kinds).
	KindUnknown ErrorKind = iota
	// KindServerBusy is Net::SMTPServerBusy (4xx).
	KindServerBusy
	// KindSyntax is Net::SMTPSyntaxError (50x).
	KindSyntax
	// KindAuthentication is Net::SMTPAuthenticationError (53x).
	KindAuthentication
	// KindFatal is Net::SMTPFatalError (other 5xx).
	KindFatal
)

func (ErrorKind) RubyClass

func (k ErrorKind) RubyClass() string

RubyClass is the fully-qualified Ruby exception class name for the kind, the constant the host raises. It lets a binding reproduce res.exception_class.

func (ErrorKind) String

func (k ErrorKind) String() string

type Response

type Response struct {
	// Status is the first three bytes of the reply (the numeric code). MRI does
	// not validate that they are digits — it is literally str[0,3].
	Status string
	// String is the human-readable reply text: the whole multi-line reply, each
	// line CRLF-chopped and "\n"-terminated.
	String string
}

Response is a server reply, mirroring Net::SMTP::Response. It holds the raw three-digit Status and the full reply String (every line, terminated by a bare "\n"), exactly as MRI's recv_response assembles it: each wire line has its trailing CRLF chopped and a single "\n" re-appended.

res := ParseResponse("250-PIPELINING\n250 HELP\n")
res.Status   // "250"
res.Success() // true

func ParseResponse

func ParseResponse(str string) *Response

ParseResponse mirrors Net::SMTP::Response.parse(str): the status is the first three bytes (str[0,3]) and the string is the whole reply verbatim. A string shorter than three bytes yields a status equal to whatever bytes exist, just as Ruby's str[0,3] returns the available prefix.

func (*Response) CRAMMD5Challenge

func (r *Response) CRAMMD5Challenge() ([]byte, error)

CRAMMD5Challenge decodes the base64 CRAM-MD5 challenge carried in the reply, mirroring Response#cram_md5_challenge: @string.split(/ /)[1].unpack1('m'). The reply is split on single spaces and the second field base64-decoded.

func (*Response) Capabilities

func (r *Response) Capabilities() map[string][]string

Capabilities parses the EHLO capability map, mirroring Response#capabilities. It returns an empty map unless the reply is multi-line (String[3,1] == "-"); then each continuation line (drop the first) is split on spaces after its "NNN-" / "NNN " prefix, keying the verb to its arguments. A duplicate verb keeps the last line's arguments, matching Ruby's hash assignment.

func (*Response) Continue

func (r *Response) Continue() bool

Continue reports a 3xx Positive Intermediate reply (Response#continue?).

func (*Response) ExceptionClass

func (r *Response) ExceptionClass() ErrorKind

ExceptionClass selects the error type for a non-success reply, mirroring Response#exception_class's case on @status: /\A4/→ServerBusy, /\A50/→Syntax, /\A53/→Authentication, /\A5/→Fatal, else→Unknown. The ordering matters: 50x is tested before the general 5xx, and 53x before it too (53 matches /\A50/? no — 53 is tested by /\A53/ which comes after /\A50/, and 530 does not start "50").

func (*Response) Message

func (r *Response) Message() string

Message is the first line of the reply text (Response#message): String.lines.first. When String is empty MRI returns nil; here that is the empty string.

func (*Response) Success

func (r *Response) Success() bool

Success reports a 2xx Positive Completion reply (Net::SMTP::Response#success?).

type SMTPError

type SMTPError struct {
	Kind     ErrorKind
	Response *Response
	Msg      string
}

SMTPError carries the kind, the offending Response, and the message. It mirrors Net::SMTPError instances, whose default message is the reply's first line (ProtocolError.new(response).message), so a host can surface the same text.

func (*SMTPError) Error

func (e *SMTPError) Error() string

Error returns the message. When no explicit message was supplied it is the response's first line, matching Net::SMTPError's default (the Ruby exception message defaults to the response's #message).

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session drives the codec against a Conn, sequencing commands and replies the way Net::SMTP does: validate_line, writeline, recv_response (multi-line aware), then check_response. It is a thin convenience over the pure functions; a host that wants finer control can call those directly.

func NewSession

func NewSession(conn Conn) *Session

NewSession wraps a Conn.

func (*Session) AuthCramMD5

func (s *Session) AuthCramMD5(user, secret string) (*Response, error)

AuthCramMD5 runs the AUTH CRAM-MD5 exchange (AuthCramMD5#auth): the initial line yields the challenge (3xx continue), whose payload is decoded and answered.

func (*Session) AuthLogin

func (s *Session) AuthLogin(user, secret string) (*Response, error)

AuthLogin runs the AUTH LOGIN exchange (AuthLogin#auth): the initial line and the user name each require a 3xx continue, then the secret requires success.

func (*Session) AuthPlain

func (s *Session) AuthPlain(user, secret string) (*Response, error)

AuthPlain runs the AUTH PLAIN exchange (AuthPlain#auth): one line, success required.

func (*Session) Capabilities

func (s *Session) Capabilities() map[string][]string

Capabilities returns the capability map from the last successful Ehlo, or nil.

func (*Session) Data

func (s *Session) Data(msg string) (*Response, error)

Data performs the DATA exchange (Net::SMTP#data): send DATA, require a 3xx continue reply (else SMTPUnknownError "could not get 3xx"), write the dot-stuffed message, then read and check the final reply.

func (*Session) Ehlo

func (s *Session) Ehlo(domain string) (*Response, error)

Ehlo sends EHLO, returns the reply, and caches its capabilities for later AUTH/SMTPUTF8 decisions (Net::SMTP#ehlo).

func (*Session) Helo

func (s *Session) Helo(domain string) (*Response, error)

Helo sends HELO and returns the reply (Net::SMTP#helo).

func (*Session) MailFrom

func (s *Session) MailFrom(from string, params ...string) (*Response, error)

MailFrom sends MAIL FROM with optional ESMTP parameters (Net::SMTP#mailfrom).

func (*Session) Quit

func (s *Session) Quit() (*Response, error)

Quit sends QUIT (Net::SMTP#quit).

func (*Session) RcptTo

func (s *Session) RcptTo(to string, params ...string) (*Response, error)

RcptTo sends RCPT TO with optional ESMTP parameters (Net::SMTP#rcptto).

func (*Session) RecvResponse

func (s *Session) RecvResponse() (*Response, error)

RecvResponse reads a full reply, mirroring Net::SMTP#recv_response: it reads lines until one whose 4th byte is not "-", assembling them (CRLF-stripped, each re-terminated with "\n") into the Response string, then parses it.

func (*Session) Rset

func (s *Session) Rset() (*Response, error)

Rset sends RSET (Net::SMTP#rset).

func (*Session) StartTLS

func (s *Session) StartTLS() (*Response, error)

StartTLS sends STARTTLS, checks the reply, then upgrades the connection (Net::SMTP#starttls returns the getok reply; the TLS handshake is the seam).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL