// 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() }