Files
gomail/internal/imap/session.go
T

206 lines
5.2 KiB
Go
Raw Normal View History

2026-08-09 18:03:09 +01:00
package imap
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/db"
)
type state int
const (
stateNotAuthenticated state = iota
stateAuthenticated
stateSelected
)
type session struct {
conn net.Conn
rw *bufio.ReadWriter
server *Server
state state
user *db.User
mailbox string
readOnly bool
tlsActive bool
// snapshot of the selected mailbox's contents at SELECT time — IMAP
// sequence numbers are defined against this snapshot, not a live query,
// per standard IMAP semantics (changes appear as untagged responses on
// the next command in a fuller implementation; this pass re-snapshots on
// every SELECT/EXAMINE, which is correct as long as the client
// re-selects to see new mail — IDLE for live push is a later addition).
entries []db.MailboxEntry
}
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: stateNotAuthenticated,
tlsActive: isTLS,
}
}
func (s *session) run(ctx context.Context) {
s.untagged(fmt.Sprintf("OK %s GoMail IMAP4rev1 ready", s.server.hostname))
for {
select {
case <-ctx.Done():
s.untagged("BYE server shutting down")
return
default:
}
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
tag, cmd, args, err := s.readCommand()
if err != nil {
if err != io.EOF {
slog.Debug("IMAP read error", "err", err)
}
return
}
if !s.dispatch(tag, cmd, args) {
return
}
}
}
// readCommand reads one line and tokenizes it into (tag, command, args).
// Literal syntax is intentionally unsupported in this pass (see parser.go
// doc comment) — a line ending in {n} is treated as a parse error rather
// than silently mishandled, so a client relying on literals gets a clear
// BAD response instead of the server hanging waiting for bytes that were
// never announced as expected.
func (s *session) readCommand() (tag, cmd string, args []string, err error) {
line, err := s.rw.ReadString('\n')
if err != nil {
return "", "", nil, err
}
line = strings.TrimRight(line, "\r\n")
if len(line) > maxCommandLine {
return "", "", nil, fmt.Errorf("command line too long")
}
tokens := tokenize(line)
if len(tokens) < 2 {
return "", "", nil, fmt.Errorf("malformed command line: %q", line)
}
return tokens[0], strings.ToUpper(tokens[1]), tokens[2:], nil
}
// dispatch runs one command. Returns false if the session should close.
func (s *session) dispatch(tag, cmd string, args []string) bool {
switch cmd {
case "CAPABILITY":
s.cmdCapability(tag)
case "STARTTLS":
s.cmdStartTLS(tag)
case "NOOP":
s.tagged(tag, "OK NOOP completed")
case "LOGOUT":
s.untagged("BYE GoMail IMAP4rev1 server logging out")
s.tagged(tag, "OK LOGOUT completed")
return false
case "LOGIN":
s.cmdLogin(tag, args)
case "AUTHENTICATE":
s.tagged(tag, "NO AUTHENTICATE not supported, use LOGIN")
case "SELECT":
s.cmdSelectExamine(tag, args, true)
case "EXAMINE":
s.cmdSelectExamine(tag, args, false)
case "LIST":
s.cmdList(tag, args)
case "LSUB":
s.cmdList(tag, args) // no separate subscription tracking yet — LSUB mirrors LIST
case "CLOSE":
s.cmdClose(tag)
case "UNSELECT":
s.mailbox = ""
s.entries = nil
s.state = stateAuthenticated
s.tagged(tag, "OK UNSELECT completed")
case "FETCH":
s.cmdFetch(tag, args, false)
case "STORE":
s.cmdStore(tag, args, false)
case "SEARCH":
s.cmdSearch(tag, args, false)
case "EXPUNGE":
s.cmdExpunge(tag)
case "UID":
s.cmdUID(tag, args)
default:
s.tagged(tag, "BAD command not recognized")
}
return true
}
func (s *session) requireAuthenticated(tag string) bool {
if s.state == stateNotAuthenticated {
s.tagged(tag, "NO command requires authentication")
return false
}
return true
}
func (s *session) requireSelected(tag string) bool {
if s.state != stateSelected {
s.tagged(tag, "NO command requires a selected mailbox")
return false
}
return true
}
// ── I/O helpers ─────────────────────────────────────────────────────────────────
func (s *session) tagged(tag, response string) {
s.rw.WriteString(tag + " " + response + "\r\n")
s.rw.Flush()
}
func (s *session) untagged(response string) {
s.rw.WriteString("* " + response + "\r\n")
s.rw.Flush()
}
func (s *session) continuation(text string) {
s.rw.WriteString("+ " + text + "\r\n")
s.rw.Flush()
}
func (s *session) upgradeTLS(tlsConf *tls.Config) error {
tlsConn := tls.Server(s.conn, tlsConf)
if err := tlsConn.HandshakeContext(context.Background()); err != nil {
return err
}
s.conn = tlsConn
s.rw = bufio.NewReadWriter(bufio.NewReader(tlsConn), bufio.NewWriter(tlsConn))
return nil
}
// authenticateUser is the shared entry point LOGIN uses.
func (s *session) authenticateUser(username, password string) bool {
user, ok := auth.Authenticate(s.server.database, username, password, auth.ScopeIMAP)
if !ok {
return false
}
s.user = user
s.state = stateAuthenticated
return true
}