first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+520
View File
@@ -0,0 +1,520 @@
// Package pop3 implements a minimal POP3 server (RFC 1939 core commands)
// for legacy clients. Off by default — enabled via config.POP3Config.Enabled.
// USER/PASS/STAT/LIST/RETR/DELE/RSET/NOOP/QUIT/UIDL/TOP — no APOP (requires
// storing plaintext-equivalent passwords, which conflicts with bcrypt-only
// storage) and no PIPELINING negotiation (POP3 has none to negotiate; most
// clients pipeline anyway and this server reads one command per line
// regardless).
package pop3
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"strconv"
"strings"
"sync"
"time"
"gomail/internal/auth"
"gomail/internal/db"
"gomail/internal/mailstore"
"gomail/internal/ratelimit"
)
const idleTimeout = 10 * time.Minute
type Server struct {
database *db.DB
store *mailstore.Store
tlsConf *tls.Config
hostname string
listeners []net.Listener
wg sync.WaitGroup
sessionWG sync.WaitGroup
authLimiter *ratelimit.Limiter // per-IP PASS failures/min — checked before credential verification
}
func NewServer(database *db.DB, store *mailstore.Store, tlsConf *tls.Config, hostname string, authFailuresPerMin int) *Server {
return &Server{
database: database,
store: store,
tlsConf: tlsConf,
hostname: hostname,
authLimiter: ratelimit.New(authFailuresPerMin),
}
}
func connHost(addr net.Addr) string {
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
func (s *Server) ListenAndServe(ctx context.Context, plainAddr, tlsAddr string) error {
specs := []struct {
addr string
useTLS bool
}{
{plainAddr, false},
{tlsAddr, true},
}
for _, spec := range specs {
ln, err := net.Listen("tcp", spec.addr)
if err != nil {
s.closeAll()
return fmt.Errorf("listen %s: %w", spec.addr, err)
}
if spec.useTLS {
ln = tls.NewListener(ln, s.tlsConf)
}
s.listeners = append(s.listeners, ln)
slog.Info("POP3 listener started", "addr", spec.addr, "implicit_tls", spec.useTLS)
s.wg.Add(1)
go func(ln net.Listener) {
defer s.wg.Done()
s.acceptLoop(ctx, ln)
}(ln)
}
<-ctx.Done()
return ctx.Err()
}
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
slog.Error("POP3 accept error", "err", err)
return
}
}
s.sessionWG.Add(1)
go func() {
defer s.sessionWG.Done()
sess := newSession(conn, s)
sess.run(ctx)
}()
}
}
func (s *Server) Shutdown(gracePeriod time.Duration) {
s.closeAll()
done := make(chan struct{})
go func() {
s.sessionWG.Wait()
close(done)
}()
select {
case <-done:
slog.Info("all POP3 sessions drained cleanly")
case <-time.After(gracePeriod):
slog.Warn("POP3 shutdown grace period expired")
}
}
func (s *Server) closeAll() {
for _, ln := range s.listeners {
ln.Close()
}
s.wg.Wait()
}
// ── Session ───────────────────────────────────────────────────────────────────
type pop3State int
const (
popAuthorization pop3State = iota
popTransaction
popUpdate
)
type session struct {
conn net.Conn
rw *bufio.ReadWriter
server *Server
state pop3State
tlsActive bool
user *db.User
pendingUser string // set by USER, consumed by PASS
// Snapshot of INBOX at login — POP3's message numbers are 1-based indexes
// into this snapshot, exactly like IMAP sequence numbers, and marked
// deleted (not removed) until QUIT commits them in the UPDATE state.
entries []db.MailboxEntry
markedDelete map[int]bool
}
func newSession(conn net.Conn, server *Server) *session {
_, isTLS := conn.(*tls.Conn)
return &session{
conn: conn,
rw: bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)),
server: server,
state: popAuthorization,
tlsActive: isTLS,
markedDelete: map[int]bool{},
}
}
func (s *session) run(ctx context.Context) {
s.reply(true, fmt.Sprintf("GoMail POP3 server ready (%s)", s.server.hostname))
for {
select {
case <-ctx.Done():
s.reply(false, "server shutting down")
return
default:
}
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
line, err := s.rw.ReadString('\n')
if err != nil {
if err != io.EOF {
slog.Debug("POP3 read error", "err", err)
}
return
}
line = strings.TrimRight(line, "\r\n")
if !s.dispatch(line) {
return
}
}
}
func (s *session) dispatch(line string) bool {
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
arg := ""
if len(parts) > 1 {
arg = parts[1]
}
switch cmd {
case "QUIT":
s.commitDeletes()
s.reply(true, "GoMail POP3 server signing off")
return false
case "USER":
s.cmdUser(arg)
case "PASS":
s.cmdPass(arg)
case "STAT":
s.cmdStat()
case "LIST":
s.cmdList(arg)
case "UIDL":
s.cmdUIDL(arg)
case "RETR":
s.cmdRetr(arg)
case "TOP":
s.cmdTop(arg)
case "DELE":
s.cmdDele(arg)
case "RSET":
s.cmdRset()
case "NOOP":
s.reply(true, "")
default:
s.reply(false, "command not recognized")
}
return true
}
func (s *session) reply(ok bool, msg string) {
prefix := "-ERR"
if ok {
prefix = "+OK"
}
if msg == "" {
s.rw.WriteString(prefix + "\r\n")
} else {
s.rw.WriteString(prefix + " " + msg + "\r\n")
}
s.rw.Flush()
}
// ── Authorization state ────────────────────────────────────────────────────────
func (s *session) cmdUser(arg string) {
if !s.tlsActive {
s.reply(false, "USER over plaintext refused — connect on the implicit-TLS port")
return
}
if s.state != popAuthorization {
s.reply(false, "command not valid in this state")
return
}
s.pendingUser = arg
s.reply(true, "user accepted, send PASS")
}
func (s *session) cmdPass(arg string) {
if !s.tlsActive {
s.reply(false, "PASS over plaintext refused — connect on the implicit-TLS port")
return
}
// Checked before attempting any credential verification — same
// rationale as smtp.session.handleAuth's authLimiter check.
ip := connHost(s.conn.RemoteAddr())
if !s.server.authLimiter.Allow(ip) {
s.reply(false, "too many authentication attempts, try again later")
return
}
if s.state != popAuthorization || s.pendingUser == "" {
s.reply(false, "USER required first")
return
}
user, ok := auth.Authenticate(s.server.database, s.pendingUser, arg, auth.ScopePOP3)
s.pendingUser = ""
if !ok {
s.reply(false, "authentication failed")
return
}
entries, err := s.server.database.ListMailboxEntries(user.ID, "INBOX")
if err != nil {
s.reply(false, "temporary error listing mailbox")
return
}
s.user = user
s.entries = entries
s.state = popTransaction
s.reply(true, fmt.Sprintf("%s's maildrop has %d message(s)", user.Email, len(entries)))
}
// ── Transaction state ────────────────────────────────────────────────────────
func (s *session) cmdStat() {
if !s.requireTransaction() {
return
}
total := int64(0)
count := 0
for i, e := range s.entries {
if s.markedDelete[i+1] {
continue
}
total += e.SizeBytes
count++
}
s.reply(true, fmt.Sprintf("%d %d", count, total))
}
func (s *session) cmdList(arg string) {
if !s.requireTransaction() {
return
}
if arg != "" {
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > len(s.entries) || s.markedDelete[n] {
s.reply(false, "no such message")
return
}
s.reply(true, fmt.Sprintf("%d %d", n, s.entries[n-1].SizeBytes))
return
}
s.reply(true, fmt.Sprintf("%d messages", s.liveCount()))
for i, e := range s.entries {
if s.markedDelete[i+1] {
continue
}
s.rw.WriteString(fmt.Sprintf("%d %d\r\n", i+1, e.SizeBytes))
}
s.rw.WriteString(".\r\n")
s.rw.Flush()
}
func (s *session) cmdUIDL(arg string) {
if !s.requireTransaction() {
return
}
if arg != "" {
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > len(s.entries) || s.markedDelete[n] {
s.reply(false, "no such message")
return
}
s.reply(true, fmt.Sprintf("%d %s", n, s.entries[n-1].ID))
return
}
s.reply(true, "unique-id listing follows")
for i, e := range s.entries {
if s.markedDelete[i+1] {
continue
}
s.rw.WriteString(fmt.Sprintf("%d %s\r\n", i+1, e.ID))
}
s.rw.WriteString(".\r\n")
s.rw.Flush()
}
func (s *session) cmdRetr(arg string) {
if !s.requireTransaction() {
return
}
n, ok := s.validMessageNum(arg)
if !ok {
return
}
entry := s.entries[n-1]
raw, err := s.server.store.Read(entry.EMLPath)
if err != nil {
s.reply(false, "error reading message")
return
}
s.reply(true, fmt.Sprintf("%d octets", len(raw)))
s.writeDotStuffed(raw)
}
func (s *session) cmdTop(arg string) {
if !s.requireTransaction() {
return
}
parts := strings.SplitN(arg, " ", 2)
if len(parts) != 2 {
s.reply(false, "TOP requires message number and line count")
return
}
n, ok := s.validMessageNum(parts[0])
if !ok {
return
}
nLines, err := strconv.Atoi(parts[1])
if err != nil || nLines < 0 {
s.reply(false, "invalid line count")
return
}
entry := s.entries[n-1]
raw, err := s.server.store.Read(entry.EMLPath)
if err != nil {
s.reply(false, "error reading message")
return
}
headerEnd := strings.Index(string(raw), "\r\n\r\n")
var header, body string
if headerEnd >= 0 {
header = string(raw[:headerEnd+4])
body = string(raw[headerEnd+4:])
} else {
header = string(raw)
}
bodyLines := strings.Split(body, "\r\n")
if nLines > len(bodyLines) {
nLines = len(bodyLines)
}
result := header + strings.Join(bodyLines[:nLines], "\r\n")
s.reply(true, "top of message follows")
s.writeDotStuffed([]byte(result))
}
func (s *session) cmdDele(arg string) {
if !s.requireTransaction() {
return
}
n, ok := s.validMessageNum(arg)
if !ok {
return
}
s.markedDelete[n] = true
s.reply(true, fmt.Sprintf("message %d marked for deletion", n))
}
func (s *session) cmdRset() {
if !s.requireTransaction() {
return
}
s.markedDelete = map[int]bool{}
s.reply(true, "maildrop state reset")
}
// commitDeletes runs at QUIT — actually removes messages marked with DELE,
// per RFC 1939 §5's UPDATE state semantics (deletion is provisional until
// QUIT; RSET or a dropped connection discards the marks instead).
func (s *session) commitDeletes() {
if s.state != popTransaction {
return
}
for i, e := range s.entries {
if s.markedDelete[i+1] {
s.server.database.DeleteMailboxEntry(e.ID)
}
}
s.state = popUpdate
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func (s *session) requireTransaction() bool {
if s.state != popTransaction {
s.reply(false, "command not valid in this state")
return false
}
return true
}
func (s *session) validMessageNum(arg string) (int, bool) {
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > len(s.entries) {
s.reply(false, "no such message")
return 0, false
}
if s.markedDelete[n] {
s.reply(false, "message already deleted")
return 0, false
}
return n, true
}
func (s *session) liveCount() int {
c := 0
for i := range s.entries {
if !s.markedDelete[i+1] {
c++
}
}
return c
}
// writeDotStuffed writes a message body with byte-stuffing (a line starting
// with "." gets an extra "." prepended) and the terminating "." line, per
// RFC 1939 §3.
func (s *session) writeDotStuffed(raw []byte) {
lines := strings.Split(string(raw), "\r\n")
for _, line := range lines {
if strings.HasPrefix(line, ".") {
s.rw.WriteString("." + line + "\r\n")
} else {
s.rw.WriteString(line + "\r\n")
}
}
s.rw.WriteString(".\r\n")
s.rw.Flush()
}