netpop

package module
v0.0.0-...-31f4fe9 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: 4 Imported by: 0

README

go-ruby-net-pop/net-pop

net-pop — go-ruby-net-pop

Docs License Go Coverage

A pure-Go (no cgo) reimplementation of the POP3 protocol codec at the heart of Ruby's Net::POP3 — MRI 4.0.5's net-pop gem (net/pop.rb layered over net/protocol.rb). It builds the POP3 command bytes, computes the APOP digest, parses the +OK/-ERR status replies, and decodes the multiline responses (the .-on-its-own-line terminator plus the leading-dot un-stuffing) into the Net::POPMail model — so the bytes on the wire and the parsed values are byte-compatible with MRI's Net::POP3, without any Ruby runtime.

It is the Net::POP3 backend for go-embedded-ruby, but is a standalone, reusable module — a sibling of go-ruby-marshal, go-ruby-yaml, go-ruby-syslog, go-ruby-regexp, and go-ruby-erb.

What it is — and isn't. The protocol codec — the command bytes (USER/PASS/APOP/STAT/LIST/UIDL/RETR/TOP/DELE/RSET/NOOP/ QUIT/STLS), the Digest::MD5.hexdigest(stamp + password) APOP digest, the /\A\+OK/i status check, the STAT/LIST/UIDL field parsing, and the multiline dot-unstuffing — is fully deterministic and needs no interpreter, so it lives here as pure Go. The socket half — opening the TCP connection, the TLS (STLS) upgrade, timeouts, the actual read/write syscalls that MRI's Net::InternetMessageIO performs — is the host's job, injected as a small Transport interface (Writeline / Readline / ReadRawLine). rbgo wires the real socket; tests use an in-memory transport, so the whole suite is deterministic and Ruby-free.

Features

A faithful port of Net::POP3's protocol layer, validated against the ruby binary on every supported platform:

  • Command codec — every command line MRI's Net::POP3Command writes, built exactly as sprintf(fmt, *args) + writeline's trailing CRLF: USER/PASS/APOP/STAT/LIST/UIDL/UIDL n/RETR n/TOP n m/DELE n/ RSET/NOOP/QUIT/STLS.
  • APOP digestApopDigest(stamp, password) reproduces Digest::MD5.hexdigest(stamp + password) (RFC 1939 §7), and ApopStamp extracts the greeting timestamp exactly as res.slice(/<[!-~]+@[!-~]+>/).
  • Status parsingIsOK / CheckResponse / CheckResponseAuth mirror the /\A\+OK/i check; the typed POPError / POPAuthenticationError / POPBadResponse hierarchy mirrors Net::POPError < ProtocolError, Net::POPAuthenticationError < ProtoAuthError, and Net::POPBadResponse < POPError.
  • Field parsingSTAT (/\A\+OK\s+(\d+)\s+(\d+)/), LIST (/\A(\d+)[ \t]+(\d+)/), multiline UIDL (line.split(' ')), and single UIDL n (res.split(/ /)[1]) → the POPMail model (number / length / UID).
  • Multiline decode — the .\r\n terminator detection and the line.delete_prefix('.') dot-unstuffing of each_message_chunk, plus the str.chop of each_list_item.

Usage

import netpop "github.com/go-ruby-net-pop/net-pop"

// Wire your TCP/TLS socket behind the Transport seam, then:
c := netpop.NewConn(transport)
if err := c.Greet(); err != nil {        // read the +OK banner, capture APOP stamp
    return err
}
if err := c.Auth("alice", "secret"); err != nil {
    return err
}
mails, err := c.List()                   // []*POPMail with Number + Length
if err != nil {
    return err
}
body, err := c.Retr(mails[0].Number)     // full message, dot-unstuffed

The pure-compute helpers (UserCommand, ApopDigest, ParseStat, ParseListItem, Unstuff, …) are exported too, so a host can drive the protocol itself and reuse only the codec.

Tests & coverage

go test keeps 100% statement coverage. The differential-vs-MRI oracle tests drive a real ruby -rnet/pop to confirm byte-parity on the command bytes, the APOP digest, the greeting-stamp regexp, the STAT/LIST/UIDL parses, and the multiline dot-unstuffing; they skip themselves where ruby is absent (the Windows lane and the qemu cross-arch lanes), so the deterministic in-memory suite alone holds the gate there. CI runs on Linux/macOS/Windows and on all six supported 64-bit architectures (amd64/arm64/riscv64/loong64/ppc64le/s390x).

go test -race -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1

License

BSD-3-Clause — see LICENSE. Copyright (c) 2026, the go-ruby-net-pop/net-pop authors.

Documentation

Overview

Package netpop is a pure-Go (CGO=0) reimplementation of the POP3 protocol codec at the heart of Ruby's Net::POP3 — MRI's net-pop gem (net/pop.rb on top of net/protocol.rb).

It builds the POP3 command bytes (USER/PASS/APOP/STAT/LIST/UIDL/RETR/TOP/DELE/ RSET/NOOP/QUIT/STLS), computes the APOP MD5 digest, parses the "+OK"/"-ERR" status replies, and decodes a multiline response — the ".\r\n" terminator plus the leading-dot un-stuffing — into the Net::POPMail model (number / length / unique-id). It matches MRI byte-for-byte on the wire bytes and on the parsed values, without any Ruby runtime.

The connect / read / write / TLS is the host's job, injected as a small Transport interface (Writeline + Readline). rbgo wires a real TCP/TLS socket; tests use an in-memory transport, so the whole suite is deterministic and Ruby-free — exactly mirroring how MRI layers Net::POP3Command over the Net::InternetMessageIO socket.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApopCommand

func ApopCommand(account, stamp, password string) string

ApopCommand builds the APOP command line: "APOP <account> <digest>", where digest is the lowercase hex MD5 of (stamp + password). This is the exact line MRI's POP3Command#apop writes.

func ApopDigest

func ApopDigest(stamp, password string) string

ApopDigest computes the APOP authentication digest exactly as MRI does: Digest::MD5.hexdigest(stamp + password), where stamp is the server-greeting timestamp token (including its surrounding angle brackets) and the result is lowercase hex. See RFC 1939 §7.

func ApopStamp

func ApopStamp(greeting string) (string, bool)

ApopStamp extracts the APOP timestamp from a server greeting line, matching MRI's POP3Command#initialize: res.slice(/<[!-~]+@[!-~]+>/). It returns the "<...@...>" token (brackets included) and ok==true, or ("", false) when the greeting carries no APOP stamp.

The Ruby regexp /<[!-~]+@[!-~]+>/ matches the *first* "<", one-or-more printable ASCII (0x21..0x7e) characters, an "@", one-or-more printable ASCII, and a ">". Because "@" and ">" are themselves in [!-~], the "+" quantifiers backtrack to the last "@" / first ">" that still satisfies the whole pattern; we reproduce that leftmost-longest-with-required-tail behaviour directly.

func CheckResponse

func CheckResponse(res string) error

CheckResponse returns a *POPError when res is not a "+OK" reply, matching MRI's POP3Command#check_response. The error carries res verbatim (MRI raises POPError with the response string).

func CheckResponseAuth

func CheckResponseAuth(res string) error

CheckResponseAuth returns a *POPAuthenticationError when res is not a "+OK" reply, matching MRI's POP3Command#check_response_auth.

func DeleCommand

func DeleCommand(num int) string

DeleCommand builds the DELE command line: "DELE <num>".

func IsOK

func IsOK(res string) bool

IsOK reports whether a status reply indicates success, matching MRI's check_response: /\A\+OK/i — a case-insensitive "+OK" anchored at the start.

func IsTerminator

func IsTerminator(rawLine string) bool

IsTerminator reports whether a raw read line (still bearing its CRLF) is the "." -on-its-own-line multiline terminator ".\r\n", matching MRI's loop guard `(line = readuntil("\r\n")) != ".\r\n"`.

func ListCommand

func ListCommand() string

ListCommand builds the multiline-LIST command line: "LIST".

func NoopCommand

func NoopCommand() string

NoopCommand builds the NOOP command line: "NOOP".

func ParseListItem

func ParseListItem(line string) (number, length int, err error)

ParseListItem parses one line of a multiline LIST response into (number, length), matching MRI's POP3Command#list: /\A(\d+)[ \t]+(\d+)/ over a line that has already had its CRLF chopped. On mismatch it returns a *POPBadResponse (MRI raises POPBadResponse, "bad response: #{line}").

func ParseStat

func ParseStat(res string) (count, size int, err error)

ParseStat parses a STAT "+OK" reply into the (count, size) pair, matching MRI's POP3Command#stat: /\A\+OK\s+(\d+)\s+(\d+)/. The reply must begin with "+OK" (any case — MRI's regexp uses a literal "+OK", but the reply has already been validated by check_response which is case-insensitive; we accept either case here for the literal "+OK", then the two whitespace-separated decimals). On a shape mismatch it returns a *POPBadResponse carrying res, exactly as MRI raises "wrong response format: <res>".

Note: MRI raises POPBadResponse with the *message* "wrong response format: #{res}". We surface res itself in the typed error and leave the human prefix to the caller's formatting, so the parsed-value comparison stays byte-exact.

func ParseUidlItem

func ParseUidlItem(line string) (number int, uid string, ok bool)

ParseUidlItem parses one line of a multiline UIDL response into (number, uid), matching MRI's POP3Command#uidl: num, uid = line.split(' '). Ruby's String#split with a single ASCII-space argument splits on runs of *any* whitespace and discards leading whitespace, so " 3 abc " yields ["3", "abc"]. We reproduce that. A line with no fields yields ok==false (MRI would leave num/uid nil; the caller skips such a degenerate line — it never reaches here for a well-formed terminator).

func ParseUidlSingle

func ParseUidlSingle(res string) string

ParseUidlSingle parses a single-message "UIDL <num>" "+OK" reply into the uid, matching MRI's POP3Command#uidl(num): res.split(/ /)[1]. Ruby's split(/ /) on a single-space *regexp* (not the special " " string) does NOT collapse runs and keeps leading empty fields, so the second field is res.split(/ /)[1]. We reproduce that exact indexing.

func PassCommand

func PassCommand(password string) string

PassCommand builds the PASS command line: "PASS <password>".

func QuitCommand

func QuitCommand() string

QuitCommand builds the QUIT command line: "QUIT".

func RetrCommand

func RetrCommand(num int) string

RetrCommand builds the RETR command line: "RETR <num>".

func RsetCommand

func RsetCommand() string

RsetCommand builds the RSET command line: "RSET".

func SetAllUIDs

func SetAllUIDs(mails []*POPMail, table map[int]string)

SetAllUIDs fills the UID of each mail from a number->uid table (as produced by Conn.UIDL), mirroring POP3#set_all_uids: m.uid = uidl[m.number]. Mails whose number is absent from the table get an empty UID.

func StatCommand

func StatCommand() string

StatCommand builds the STAT command line: "STAT".

func StlsCommand

func StlsCommand() string

StlsCommand builds the STLS command line: "STLS" (RFC 2595 STARTTLS for POP3).

func TopCommand

func TopCommand(num, lines int) string

TopCommand builds the TOP command line: "TOP <num> <lines>". Equivalent to sprintf('TOP %d %d', num, lines).

func UidlCommand

func UidlCommand() string

UidlCommand builds the multiline-UIDL command line: "UIDL".

func UidlNumCommand

func UidlNumCommand(num int) string

UidlNumCommand builds the single-message UIDL command line: "UIDL <num>". Equivalent to sprintf('UIDL %d', num).

func Unstuff

func Unstuff(line string) string

Unstuff removes one leading "." from a multiline message chunk, matching MRI's each_message_chunk: line.delete_prefix('.'). Only a single leading dot is stripped (so a line that was ".." on the wire becomes "."), and a line with no leading dot is returned unchanged.

func UserCommand

func UserCommand(account string) string

UserCommand builds the USER command line (without the trailing CRLF): "USER <account>". Equivalent to sprintf('USER %s', account).

func Writeline

func Writeline(line string) string

Writeline renders a command line as the bytes MRI's InternetMessageIO#writeline puts on the wire: the line plus a single CRLF.

Types

type Conn

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

Conn drives a POP3 session over a Transport. It is the Go analogue of MRI's Net::POP3Command: it issues the commands, validates the "+OK"/"-ERR" status replies, parses STAT/LIST/UIDL, and decodes the multiline RETR/TOP bodies — all without owning the socket.

func NewConn

func NewConn(t Transport) *Conn

NewConn returns a Conn driving the given Transport. The greeting is not read here; call Greet (mirroring how MRI's POP3Command#initialize reads the banner) before authenticating.

func (*Conn) Apop

func (c *Conn) Apop(account, password string) error

Apop performs APOP authentication, mirroring POP3Command#apop. It requires that the greeting carried an APOP stamp (else MRI raises POPAuthenticationError, "not APOP server; cannot login"). A non-"+OK" reply yields a *POPAuthenticationError.

func (*Conn) ApopStamp

func (c *Conn) ApopStamp() (string, bool)

ApopStamp returns the APOP timestamp captured from the greeting, and whether one was present. Valid only after Greet.

func (*Conn) Auth

func (c *Conn) Auth(account, password string) error

Auth performs USER/PASS authentication, mirroring POP3Command#auth: send USER, require "+OK", send PASS, require "+OK". A non-"+OK" reply at either step yields a *POPAuthenticationError.

func (*Conn) Dele

func (c *Conn) Dele(num int) error

Dele issues "DELE <num>", mirroring POP3Command#dele. A non-"+OK" reply yields a *POPError.

func (*Conn) Greet

func (c *Conn) Greet() error

Greet reads and validates the server greeting, capturing the APOP stamp, mirroring MRI's POP3Command#initialize:

res = check_response(critical { recv_response() })
@apop_stamp = res.slice(/<[!-~]+@[!-~]+>/)

It returns a *POPError when the greeting is not "+OK".

func (*Conn) List

func (c *Conn) List() ([]*POPMail, error)

List issues a multiline LIST and returns one POPMail per message (number + length; uid empty until set via SetAllUIDs), mirroring POP3Command#list mapped through POP3#mails.

func (*Conn) Noop

func (c *Conn) Noop() error

Noop issues NOOP and requires a "+OK" reply. NOOP is not used by MRI's net-pop, but is a standard no-argument POP3 keepalive (RFC 1939); we validate the reply the same way the other single-line commands do.

func (*Conn) Quit

func (c *Conn) Quit() error

Quit issues QUIT, mirroring POP3Command#quit.

func (*Conn) Retr

func (c *Conn) Retr(num int) (string, error)

Retr issues "RETR <num>" and returns the full message bytes, mirroring POP3Command#retr feeding each_message_chunk. Each multiline chunk is dot-unstuffed; the ".\r\n" terminator ends the body. The returned bytes are the concatenation of the (CRLF-bearing) un-stuffed chunks — exactly what MRI's POPMail#pop accumulates.

func (*Conn) Rset

func (c *Conn) Rset() error

Rset issues RSET, mirroring POP3Command#rset.

func (*Conn) Stat

func (c *Conn) Stat() (count, size int, err error)

Stat issues STAT and returns (count, totalSize), mirroring POP3Command#stat.

func (*Conn) Stls

func (c *Conn) Stls() error

Stls issues STLS (RFC 2595 STARTTLS for POP3) and requires a "+OK" reply. After a "+OK" the host is expected to upgrade the underlying Transport to TLS; that upgrade is the socket seam's concern, not this codec's.

func (*Conn) Top

func (c *Conn) Top(num, lines int) (string, error)

Top issues "TOP <num> <lines>" and returns the header plus the first `lines` body lines, mirroring POP3Command#top.

func (*Conn) UIDL

func (c *Conn) UIDL() (map[int]string, error)

UIDL issues a multiline UIDL and returns a number->uid table, mirroring POP3Command#uidl (no argument).

func (*Conn) UIDLNum

func (c *Conn) UIDLNum(num int) (string, error)

UIDLNum issues a single-message "UIDL <num>" and returns its uid, mirroring POP3Command#uidl(num).

type POPAuthenticationError

type POPAuthenticationError struct {
	Response string
}

POPAuthenticationError is raised when authentication (USER/PASS or APOP) fails. It corresponds to Net::POPAuthenticationError.

func (*POPAuthenticationError) Error

func (e *POPAuthenticationError) Error() string

type POPBadResponse

type POPBadResponse struct {
	Response string
}

POPBadResponse is raised when the server returns a reply that does not match the expected shape for STAT/LIST/UIDL. It corresponds to Net::POPBadResponse.

func (*POPBadResponse) Error

func (e *POPBadResponse) Error() string

type POPError

type POPError struct {
	// Response is the raw server line that triggered the error, with its CRLF
	// terminator already stripped — exactly the String MRI passes to raise.
	Response string
}

POPError is the base error: an ordinary non-authentication "-ERR" reply (or any reply that does not begin with "+OK"). It corresponds to Net::POPError.

func (*POPError) Error

func (e *POPError) Error() string

type POPMail

type POPMail struct {
	// Number is the message's sequence number on the server (Net::POPMail#number).
	Number int
	// Length is the message size in octets (Net::POPMail#length / #size).
	Length int
	// UID is the message's unique-id (Net::POPMail#unique_id / #uidl), empty until
	// populated from a UIDL response.
	UID string
	// Deleted records whether the message has been marked for deletion this
	// session (Net::POPMail#deleted?). DELE on the server defers actual removal to
	// session end; RSET clears the mark.
	Deleted bool
}

POPMail is the Go analogue of Net::POPMail: a message that exists on the POP server, identified by its sequence Number, with its Length in octets and, optionally, its unique-id (UID). MRI constructs these from the LIST reply and fills the UID lazily from a UIDL reply.

Unlike MRI's POPMail, this struct holds no back-reference to a live session and performs no I/O — it is the parsed data model. The session-driving (pop / top / delete / unique_id) lives on Conn, which owns the Transport seam.

func (*POPMail) Size

func (m *POPMail) Size() int

Size returns the message length in octets, mirroring Net::POPMail#size (an alias of #length).

type Transport

type Transport interface {
	// Writeline writes one command line, appending CRLF.
	Writeline(line string) error
	// Readline reads one response line and returns it without the trailing CRLF.
	Readline() (string, error)
	// ReadRawLine reads one line of a multiline body and returns it WITH its
	// trailing CRLF intact, mirroring Net::BufferedIO#readuntil("\r\n"). The
	// terminator detection ((line) != ".\r\n") and the dot-unstuffing depend on
	// the CRLF being present, so multiline reads use this rather than Readline.
	ReadRawLine() (string, error)
}

Transport is the socket seam. It is the pure-compute boundary of this package: everything above it (command bytes, APOP digest, response parsing, multiline dot-unstuffing, the POPMail model) is deterministic and Ruby-free; everything below it (TCP connect, TLS handshake, timeouts, the actual read/write syscalls) is the host's job.

It mirrors the two methods MRI's POP3Command leans on from Net::InternetMessageIO:

  • Writeline(line) corresponds to writeline(str): the implementation MUST append a single CRLF — exactly what Net::InternetMessageIO#writeline does. Callers therefore pass the bare command line (e.g. "STAT", "RETR 3").
  • Readline() corresponds to readline: it returns one response line WITHOUT its trailing CRLF (Net::BufferedIO#readline strips it). The status-line parsers here expect that stripped form.

rbgo wires a real *net.Conn / *tls.Conn behind this; tests use an in-memory transport, so the whole suite is deterministic.

Jump to

Keyboard shortcuts

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