From 43f2e61e713d73526866330c369bf7a4f9c8b802 Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Sun, 9 Nov 2025 21:13:18 -0500 Subject: [PATCH] Implement a POP3 client of the interfaces defined in pkg/pop3. --- pkg/pop3/client.go | 190 ++++++++++++++++++++++++++++++++++++++++ pkg/pop3/client_test.go | 174 ++++++++++++++++++++++++++++++++++++ pkg/pop3/conn.go | 3 + pkg/pop3/conn_test.go | 2 +- 4 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 pkg/pop3/client.go create mode 100644 pkg/pop3/client_test.go diff --git a/pkg/pop3/client.go b/pkg/pop3/client.go new file mode 100644 index 0000000..a731916 --- /dev/null +++ b/pkg/pop3/client.go @@ -0,0 +1,190 @@ +// mailpopbox +// Copyright 2025 Blue Static +// This program is free software licensed under the GNU General Public License, +// version 3.0. The full text of the license can be found in LICENSE.txt. +// SPDX-License-Identifier: GPL-3.0-only + +package pop3 + +import ( + "fmt" + "io" + "net" + "net/textproto" + "strings" + + "go.uber.org/zap" +) + +type serverConn struct { + name string + tp *textproto.Conn + log *zap.Logger + loggedIn bool + deleted map[int]struct{} +} + +// Connect connects to a POP3 server and returns the `PostOffice` for accesing +// mailboxes. +func Connect(nc net.Conn, log *zap.Logger) (PostOffice, error) { + log = log.With(zap.Stringer("address", nc.RemoteAddr())) + conn := &serverConn{ + tp: textproto.NewConn(nc), + log: log, + deleted: make(map[int]struct{}), + } + var err error + conn.name, err = conn.readReplyLine() + if err != nil { + return nil, fmt.Errorf("Failed to open connection: %w", err) + } + return conn, nil +} + +func (sc *serverConn) Name() string { + return sc.name +} + +func (sc *serverConn) OpenMailbox(user, pass string) (Mailbox, error) { + if sc.loggedIn { + return nil, fmt.Errorf("Mailbox is already open") + } + if _, err := sc.transaction("USER %s", user); err != nil { + return nil, err + } + if _, err := sc.transaction("PASS %s", pass); err != nil { + return nil, err + } + sc.log.Info("Opened mailbox") + sc.loggedIn = true + return sc, nil +} + +func (sc *serverConn) transaction(fmt string, args ...any) (string, error) { + log := sc.log.With(zap.String("command", fmt)) + log.Debug("Sending transaction") + if err := sc.tp.PrintfLine(fmt, args...); err != nil { + log.Error("Failed to send command") + return "", err + } + reply, err := sc.readReplyLine() + if err != nil { + log.Error("Command failed", zap.Error(err)) + return reply, err + } + log.Info("Command succeeded", zap.String("reply", reply)) + return reply, nil +} + +func (sc *serverConn) readReplyLine() (string, error) { + line, err := sc.tp.ReadLine() + if err != nil { + return line, err + } + if strings.HasPrefix(line, "+OK") { + return strings.TrimPrefix(line[3:], " "), nil + } + if strings.HasPrefix(line, "-ERR") { + return "", fmt.Errorf("Server error: %s", line[4:]) + } + return "", fmt.Errorf("Unexpected server reply: %q", line) +} + +func (sc *serverConn) ListMessages() ([]Message, error) { + _, err := sc.transaction("LIST") + if err != nil { + return nil, err + } + lines, err := sc.tp.ReadDotLines() + if err != nil { + return nil, err + } + msgs := make([]Message, len(lines)) + for i, line := range lines { + msg := sc.parseMessageListLine(line) + if msg == nil { + sc.log.Error("Bad server message line", zap.Int("index", i), zap.String("line", line)) + return nil, fmt.Errorf("Bad server reply") + } + msgs[i] = msg + } + return msgs, nil +} + +func (sc *serverConn) GetMessage(id int) Message { + ls, err := sc.transaction("LIST %d", id) + if err != nil { + return nil + } + lines, err := sc.tp.ReadDotLines() + if err != nil { + return nil + } + if len(lines) != 1 { + sc.log.Error("Server returned incorrect number of lines", zap.Strings("lines", lines)) + return nil + } + msg := sc.parseMessageListLine(lines[0]) + if msg == nil { + sc.log.Error("Bad server message line", zap.String("reply", ls)) + } + return msg +} + +func (sc *serverConn) parseMessageListLine(line string) *serverMessage { + var sid, size int + n, err := fmt.Sscanf(line, "%d %d", &sid, &size) + if n != 2 || err != nil { + sc.log.Error("Failed to parse message line", zap.Int("numItems", n), zap.Error(err)) + return nil + } + return &serverMessage{ + sc: sc, + id: sid, + size: size, + } +} + +func (sc *serverConn) Retrieve(msg Message) (io.ReadCloser, error) { + _, err := sc.transaction("RETR %d", msg.ID()) + if err != nil { + return nil, err + } + return io.NopCloser(sc.tp.DotReader()), nil +} + +func (sc *serverConn) Delete(msg Message) error { + _, err := sc.transaction("DELE %d", msg.ID()) + if err == nil { + sc.deleted[msg.ID()] = struct{}{} + } + return err +} + +func (sc *serverConn) Close() error { + if _, err := sc.transaction("QUIT"); err != nil { + return err + } + sc.tp.Close() + return nil +} + +func (sc *serverConn) Reset() { + if _, err := sc.transaction("RSET"); err == nil { + sc.deleted = make(map[int]struct{}) + } +} + +type serverMessage struct { + sc *serverConn + id int + size int +} + +func (m *serverMessage) UniqueID() string { return "" } +func (m *serverMessage) ID() int { return m.id } +func (m *serverMessage) Size() int { return m.size } +func (m *serverMessage) Deleted() bool { + _, deleted := m.sc.deleted[m.id] + return deleted +} diff --git a/pkg/pop3/client_test.go b/pkg/pop3/client_test.go new file mode 100644 index 0000000..3f0c055 --- /dev/null +++ b/pkg/pop3/client_test.go @@ -0,0 +1,174 @@ +// mailpopbox +// Copyright 2025 Blue Static +// This program is free software licensed under the GNU General Public License, +// version 3.0. The full text of the license can be found in LICENSE.txt. +// SPDX-License-Identifier: GPL-3.0-only + +package pop3 + +import ( + "io" + "net" + "testing" + + "go.uber.org/zap" +) + +// RFC 1939 § 10 +func TestClientExampleSession(t *testing.T) { + s := newTestServer() + l := runServer(t, s) + defer l.Close() + + s.mb.msgs[1] = &testMessage{1, 120, false, ""} + s.mb.msgs[2] = &testMessage{2, 200, false, ""} + + dc, err := net.Dial(l.Addr().Network(), l.Addr().String()) + ok(t, err) + + c, err := Connect(dc, zap.L()) + ok(t, err) + + mb, err := c.OpenMailbox("u", "p") + ok(t, err) + + msgs, err := mb.ListMessages() + ok(t, err) + if want, got := 2, len(msgs); want != got { + t.Errorf("Expected %d messages, got %d", want, got) + } + + if want, got := 1, msgs[0].ID(); want != got { + t.Errorf("Expected message ID %d, got %d", want, got) + } + if want, got := 120, msgs[0].Size(); want != got { + t.Errorf("Expected message size %d, got %d", want, got) + } + + if want, got := 2, msgs[1].ID(); want != got { + t.Errorf("Expected message ID %d, got %d", want, got) + } + if want, got := 200, msgs[1].Size(); want != got { + t.Errorf("Expected message size %d, got %d", want, got) + } + + ok(t, mb.Close()) +} + +func TestClientRetrieve(t *testing.T) { + s := newTestServer() + l := runServer(t, s) + defer l.Close() + + body := `This is a test message. +It contains HTML + +and ------ +---. +. +Boundary items +` + + s.mb.msgs[1] = &testMessage{1, len(body), false, body} + + dc, err := net.Dial(l.Addr().Network(), l.Addr().String()) + ok(t, err) + + c, err := Connect(dc, zap.L()) + ok(t, err) + + mb, err := c.OpenMailbox("u", "p") + ok(t, err) + + msgs, err := mb.ListMessages() + ok(t, err) + if want, got := 1, len(msgs); want != got { + t.Errorf("Expected %d messages, got %d", want, got) + } + + rc, err := mb.Retrieve(msgs[0]) + ok(t, err) + + got, err := io.ReadAll(rc) + ok(t, err) + rc.Close() + + if string(got) != body { + t.Errorf("Expected body %q, got %q", body, string(got)) + } + + ok(t, mb.Close()) +} + +func TestClientErrors(t *testing.T) { + s := newTestServer() + l := runServer(t, s) + defer l.Close() + + s.mb.msgs[1] = &testMessage{1, 12, false, "hello world"} + + dc, err := net.Dial(l.Addr().Network(), l.Addr().String()) + ok(t, err) + + c, err := Connect(dc, zap.L()) + ok(t, err) + + mb, err := c.OpenMailbox("bad", "p") + if mb != nil || err == nil { + t.Errorf("Expected error, got %v %v", mb, err) + } + + mb, err = c.OpenMailbox("u", "bad") + if mb != nil || err == nil { + t.Errorf("Expected error, got %v %v", mb, err) + } + + mb, err = c.OpenMailbox("u", "p") + ok(t, err) + + _, err = c.OpenMailbox("bad", "x") + if err == nil { + t.Errorf("Shouldn't reopen mailbox") + } + + msg := mb.GetMessage(100) + if msg != nil { + t.Errorf("Should have failed to get message") + } + + msgs, err := mb.ListMessages() + ok(t, err) + + msg = msgs[0] + if msg.Deleted() { + t.Errorf("Expected message to not be marked as deleted") + } + + ok(t, mb.Delete(msg)) + + if !msg.Deleted() { + t.Errorf("Expected message to be marked as deleted") + } + + body, err := mb.Retrieve(msg) + if body != nil || err == nil { + t.Errorf("Expected error, got %v %v", msg, err) + } + msg2 := mb.GetMessage(1) + if msg2 != nil { + t.Errorf("Shouldn't get deleted message") + } + + mb.Reset() + if msg.Deleted() { + t.Errorf("Expected message to not be marked as deleted") + } + + msg2 = mb.GetMessage(1) + if msg2 == nil { + t.Errorf("Failed to get message") + } + + body, err = mb.Retrieve(msg) + ok(t, err) +} diff --git a/pkg/pop3/conn.go b/pkg/pop3/conn.go index e3737bf..a8723a1 100644 --- a/pkg/pop3/conn.go +++ b/pkg/pop3/conn.go @@ -46,6 +46,9 @@ type connection struct { user string } +// AcceptConnection implements a POP3 server connection, parsing the client +// requests sent over `netConn` and providing access to the mailboxes in the +// specified `PostOffice`. func AcceptConnection(netConn net.Conn, po PostOffice, log *zap.Logger) { log = log.With(zap.Stringer("client", netConn.RemoteAddr())) conn := connection{ diff --git a/pkg/pop3/conn_test.go b/pkg/pop3/conn_test.go index c90ee0d..2cfdc46 100644 --- a/pkg/pop3/conn_test.go +++ b/pkg/pop3/conn_test.go @@ -68,7 +68,7 @@ func runServer(t *testing.T, po PostOffice) net.Listener { if err != nil { return } - go AcceptConnection(conn, po, zap.NewNop()) + go AcceptConnection(conn, po, zap.L()) } }() return l -- 2.43.5