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 }