521 lines
12 KiB
Go
521 lines
12 KiB
Go
// 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()
|
|
}
|