206 lines
5.2 KiB
Go
206 lines
5.2 KiB
Go
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
|
||
|
|
}
|