first commit
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user