first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+518
View File
@@ -0,0 +1,518 @@
package imap
import (
"fmt"
"strconv"
"strings"
"gomail/internal/db"
)
func (s *session) cmdCapability(tag string) {
caps := "CAPABILITY IMAP4rev1"
if !s.tlsActive {
caps += " STARTTLS LOGINDISABLED"
} else {
caps += " AUTH=LOGIN"
}
s.untagged(caps)
s.tagged(tag, "OK CAPABILITY completed")
}
func (s *session) cmdStartTLS(tag string) {
if s.tlsActive {
s.tagged(tag, "BAD TLS already active")
return
}
s.tagged(tag, "OK begin TLS negotiation now")
if err := s.upgradeTLS(s.server.tlsConf); err != nil {
return // connection is likely unusable now; caller's read loop will error out and close
}
s.tlsActive = true
}
func (s *session) cmdLogin(tag string, args []string) {
if !s.tlsActive {
s.tagged(tag, "NO LOGIN over plaintext refused — use STARTTLS or 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.tagged(tag, "NO too many authentication attempts, try again later")
return
}
if len(args) < 2 {
s.tagged(tag, "BAD LOGIN requires username and password")
return
}
username, password := args[0], args[1]
if !s.authenticateUser(username, password) {
s.tagged(tag, "NO LOGIN failed")
return
}
s.tagged(tag, "OK LOGIN completed")
}
func (s *session) cmdSelectExamine(tag string, args []string, readWrite bool) {
if !s.requireAuthenticated(tag) {
return
}
if len(args) < 1 {
s.tagged(tag, "BAD SELECT/EXAMINE requires a mailbox name")
return
}
mailbox := args[0]
entries, err := s.server.database.ListMailboxEntries(s.user.ID, mailbox)
if err != nil {
s.tagged(tag, "NO SELECT failed: "+err.Error())
return
}
s.mailbox = mailbox
s.entries = entries
s.readOnly = !readWrite
s.state = stateSelected
unseen := 0
nextUID := 1
for i, e := range entries {
if !strings.Contains(e.Flags, "\\Seen") && unseen == 0 {
s.untagged(fmt.Sprintf("OK [UNSEEN %d] first unseen", i+1))
unseen = i + 1
}
if e.UID >= nextUID {
nextUID = e.UID + 1
}
}
s.untagged(fmt.Sprintf("%d EXISTS", len(entries)))
s.untagged("0 RECENT")
s.untagged("FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)")
s.untagged("OK [PERMANENTFLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)] Limited")
s.untagged("OK [UIDVALIDITY 1] UIDs valid")
s.untagged(fmt.Sprintf("OK [UIDNEXT %d] Predicted next UID", nextUID))
if readWrite {
s.tagged(tag, "OK [READ-WRITE] SELECT completed")
} else {
s.tagged(tag, "OK [READ-ONLY] EXAMINE completed")
}
}
func (s *session) cmdList(tag string, args []string) {
if !s.requireAuthenticated(tag) {
return
}
// args: reference-name mailbox-pattern — we ignore hierarchy and just
// list every mailbox the user has, since GoMail's folder model is flat
// (no nested folders yet). A "%"/"*" wildcard pattern matches everything
// in this simplified model.
names, err := s.server.database.ListMailboxNames(s.user.ID)
if err != nil {
s.tagged(tag, "NO LIST failed: "+err.Error())
return
}
for _, name := range names {
s.untagged(fmt.Sprintf(`LIST () "/" %s`, quoteIfNeeded(name)))
}
s.tagged(tag, "OK LIST completed")
}
func (s *session) cmdClose(tag string) {
if !s.requireSelected(tag) {
return
}
s.expungeDeleted()
s.mailbox = ""
s.entries = nil
s.state = stateAuthenticated
s.tagged(tag, "OK CLOSE completed")
}
func (s *session) cmdExpunge(tag string) {
if !s.requireSelected(tag) {
return
}
if s.readOnly {
s.tagged(tag, "NO mailbox is read-only")
return
}
removed := s.expungeDeleted()
s.tagged(tag, fmt.Sprintf("OK EXPUNGE completed (%d removed)", removed))
}
// expungeDeleted removes every \Deleted-flagged message from storage and the
// index, sends the required untagged "N EXPUNGE" responses (in descending
// sequence order, per RFC 3501 §6.4.3 — removing from the end first keeps
// earlier sequence numbers stable for any remaining EXPUNGE responses in the
// same batch), and refreshes the in-memory snapshot.
func (s *session) expungeDeleted() int {
var kept []db.MailboxEntry
var removedSeqs []int
for i, e := range s.entries {
if strings.Contains(e.Flags, "\\Deleted") {
removedSeqs = append(removedSeqs, i+1)
s.server.database.DeleteMailboxEntry(e.ID)
// Best-effort file removal — the DB row is the source of truth for
// "does this message exist"; a leftover encrypted file with no
// index row is inert.
} else {
kept = append(kept, e)
}
}
for i := len(removedSeqs) - 1; i >= 0; i-- {
s.untagged(fmt.Sprintf("%d EXPUNGE", removedSeqs[i]))
}
s.entries = kept
return len(removedSeqs)
}
func (s *session) cmdUID(tag string, args []string) {
if len(args) < 1 {
s.tagged(tag, "BAD UID requires a subcommand")
return
}
sub := strings.ToUpper(args[0])
rest := args[1:]
switch sub {
case "FETCH":
s.cmdFetch(tag, rest, true)
case "STORE":
s.cmdStore(tag, rest, true)
case "SEARCH":
s.cmdSearch(tag, rest, true)
default:
s.tagged(tag, "BAD UID subcommand not recognized")
}
}
// ── FETCH ─────────────────────────────────────────────────────────────────────
func (s *session) cmdFetch(tag string, args []string, byUID bool) {
if !s.requireSelected(tag) {
return
}
if len(args) < 2 {
s.tagged(tag, "BAD FETCH requires a sequence-set and item list")
return
}
targets := s.resolveSequenceSet(args[0], byUID)
items := expandFetchItems(args[1])
for _, idx := range targets {
entry := s.entries[idx]
s.sendFetchResponse(idx+1, entry, items)
}
s.tagged(tag, "OK FETCH completed")
}
func expandFetchItems(token string) []string {
var items []string
if isList(token) {
items = splitList(token)
} else {
items = []string{token}
}
var expanded []string
for _, item := range items {
switch strings.ToUpper(item) {
case "FAST":
expanded = append(expanded, "FLAGS", "INTERNALDATE", "RFC822.SIZE")
case "ALL":
expanded = append(expanded, "FLAGS", "INTERNALDATE", "RFC822.SIZE")
case "FULL":
expanded = append(expanded, "FLAGS", "INTERNALDATE", "RFC822.SIZE", "BODY[]")
default:
expanded = append(expanded, item)
}
}
return expanded
}
func (s *session) sendFetchResponse(seq int, entry db.MailboxEntry, items []string) {
var parts []string
markSeen := false
for _, item := range items {
upper := strings.ToUpper(item)
switch {
case upper == "FLAGS":
parts = append(parts, "FLAGS ("+flagsToIMAP(entry.Flags)+")")
case upper == "UID":
parts = append(parts, fmt.Sprintf("UID %d", entry.UID))
case upper == "RFC822.SIZE":
parts = append(parts, fmt.Sprintf("RFC822.SIZE %d", entry.SizeBytes))
case upper == "INTERNALDATE":
parts = append(parts, fmt.Sprintf(`INTERNALDATE "%s"`, entry.InternalDate.Format("02-Jan-2006 15:04:05 -0700")))
case upper == "BODY[]" || upper == "RFC822":
raw, err := s.server.store.Read(entry.EMLPath)
if err == nil {
parts = append(parts, fmt.Sprintf("BODY[] {%d}\r\n%s", len(raw), raw))
markSeen = true
}
case upper == "BODY.PEEK[]":
raw, err := s.server.store.Read(entry.EMLPath)
if err == nil {
parts = append(parts, fmt.Sprintf("BODY[] {%d}\r\n%s", len(raw), raw))
}
case upper == "BODY[HEADER]" || upper == "RFC822.HEADER" || upper == "BODY.PEEK[HEADER]":
raw, err := s.server.store.Read(entry.EMLPath)
if err == nil {
headers := extractHeaders(raw)
parts = append(parts, fmt.Sprintf("BODY[HEADER] {%d}\r\n%s", len(headers), headers))
if upper == "RFC822.HEADER" {
markSeen = true
}
}
}
}
if markSeen && !strings.Contains(entry.Flags, "\\Seen") {
newFlags := addFlag(entry.Flags, "\\Seen")
s.server.database.UpdateMailboxFlags(entry.ID, newFlags)
for i := range s.entries {
if s.entries[i].ID == entry.ID {
s.entries[i].Flags = newFlags
}
}
}
s.untagged(fmt.Sprintf("%d FETCH (%s)", seq, strings.Join(parts, " ")))
}
func extractHeaders(raw []byte) []byte {
sep := []byte("\r\n\r\n")
if idx := indexOf(raw, sep); idx >= 0 {
return raw[:idx+2]
}
return raw
}
func indexOf(haystack, needle []byte) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
// ── STORE ─────────────────────────────────────────────────────────────────────
func (s *session) cmdStore(tag string, args []string, byUID bool) {
if !s.requireSelected(tag) {
return
}
if s.readOnly {
s.tagged(tag, "NO mailbox is read-only")
return
}
if len(args) < 3 {
s.tagged(tag, "BAD STORE requires sequence-set, item, and flag list")
return
}
targets := s.resolveSequenceSet(args[0], byUID)
action := strings.ToUpper(args[1])
newFlags := splitList(args[2])
if len(newFlags) == 0 {
newFlags = args[2:]
}
silent := strings.Contains(action, ".SILENT")
for _, idx := range targets {
entry := &s.entries[idx]
switch {
case strings.HasPrefix(action, "+FLAGS"):
for _, f := range newFlags {
entry.Flags = addFlag(entry.Flags, f)
}
case strings.HasPrefix(action, "-FLAGS"):
for _, f := range newFlags {
entry.Flags = removeFlag(entry.Flags, f)
}
case strings.HasPrefix(action, "FLAGS"):
entry.Flags = strings.Join(newFlags, " ")
default:
continue
}
s.server.database.UpdateMailboxFlags(entry.ID, entry.Flags)
if !silent {
s.untagged(fmt.Sprintf("%d FETCH (FLAGS (%s))", idx+1, flagsToIMAP(entry.Flags)))
}
}
s.tagged(tag, "OK STORE completed")
}
func addFlag(flags, flag string) string {
if strings.Contains(flags, flag) {
return flags
}
if flags == "" {
return flag
}
return flags + " " + flag
}
func removeFlag(flags, flag string) string {
parts := strings.Fields(flags)
var out []string
for _, p := range parts {
if p != flag {
out = append(out, p)
}
}
return strings.Join(out, " ")
}
func flagsToIMAP(flags string) string {
return flags // stored representation already matches IMAP flag syntax
}
// ── SEARCH ────────────────────────────────────────────────────────────────────
func (s *session) cmdSearch(tag string, args []string, byUID bool) {
if !s.requireSelected(tag) {
return
}
if len(args) == 0 {
s.tagged(tag, "BAD SEARCH requires criteria")
return
}
var matches []int
for i, entry := range s.entries {
if matchesSearch(entry, args) {
if byUID {
matches = append(matches, entry.UID)
} else {
matches = append(matches, i+1)
}
}
}
strs := make([]string, len(matches))
for i, m := range matches {
strs[i] = strconv.Itoa(m)
}
s.untagged("SEARCH " + strings.Join(strs, " "))
s.tagged(tag, "OK SEARCH completed")
}
// matchesSearch supports a pragmatic subset: ALL, UNSEEN, SEEN, ANSWERED,
// DELETED, FLAGGED, plus one-shot FROM/SUBJECT substring matching (checked
// against the flags string / a lightweight header scan). Full IMAP SEARCH
// grammar (nested boolean groups, date ranges, OR) is deferred.
func matchesSearch(entry db.MailboxEntry, criteria []string) bool {
for i := 0; i < len(criteria); i++ {
switch strings.ToUpper(criteria[i]) {
case "ALL":
continue
case "UNSEEN":
if strings.Contains(entry.Flags, "\\Seen") {
return false
}
case "SEEN":
if !strings.Contains(entry.Flags, "\\Seen") {
return false
}
case "ANSWERED":
if !strings.Contains(entry.Flags, "\\Answered") {
return false
}
case "DELETED":
if !strings.Contains(entry.Flags, "\\Deleted") {
return false
}
case "FLAGGED":
if !strings.Contains(entry.Flags, "\\Flagged") {
return false
}
}
}
return true
}
// ── Sequence set resolution ────────────────────────────────────────────────────
// resolveSequenceSet parses "1", "1:3", "1,3,5", "1:*" (sequence numbers) or
// the equivalent for UIDs when byUID is true, and returns 0-based indexes
// into s.entries.
func (s *session) resolveSequenceSet(spec string, byUID bool) []int {
var result []int
seen := map[int]bool{}
for _, part := range strings.Split(spec, ",") {
var lo, hi int
if strings.Contains(part, ":") {
bounds := strings.SplitN(part, ":", 2)
lo = parseSeqNum(bounds[0], byUID, s.entries)
hi = parseSeqNum(bounds[1], byUID, s.entries)
if lo > hi {
lo, hi = hi, lo
}
} else {
lo = parseSeqNum(part, byUID, s.entries)
hi = lo
}
for i, e := range s.entries {
var val int
if byUID {
val = e.UID
} else {
val = i + 1
}
if val >= lo && val <= hi && !seen[i] {
seen[i] = true
result = append(result, i)
}
}
}
return result
}
func parseSeqNum(s string, byUID bool, entries []db.MailboxEntry) int {
if s == "*" {
if len(entries) == 0 {
return 0
}
if byUID {
return entries[len(entries)-1].UID
}
return len(entries)
}
n, err := strconv.Atoi(s)
if err != nil {
return 0
}
return n
}
func quoteIfNeeded(name string) string {
if strings.ContainsAny(name, " \t()\"") {
return `"` + strings.ReplaceAll(name, `"`, `\"`) + `"`
}
return name
}
+77
View File
@@ -0,0 +1,77 @@
package imap
import "strings"
// tokenize splits an IMAP command line into space-separated tokens, treating
// "quoted strings" and (parenthesized lists) as single tokens (lists keep
// their outer parens so command handlers can recognize and further split
// them). Literal syntax ({n}\r\n<bytes>) is not handled here — see session.go's
// readCommand, which handles literals as a pre-pass before tokenizing since
// they require reading raw bytes off the connection, not just string scanning.
func tokenize(line string) []string {
var tokens []string
i, n := 0, len(line)
for i < n {
for i < n && (line[i] == ' ' || line[i] == '\t') {
i++
}
if i >= n {
break
}
switch line[i] {
case '"':
j := i + 1
var sb strings.Builder
for j < n && line[j] != '"' {
if line[j] == '\\' && j+1 < n {
j++
}
sb.WriteByte(line[j])
j++
}
tokens = append(tokens, sb.String())
i = j + 1
case '(':
depth := 1
j := i + 1
for j < n && depth > 0 {
switch line[j] {
case '(':
depth++
case ')':
depth--
}
j++
}
tokens = append(tokens, line[i:j])
i = j
default:
j := i
for j < n && line[j] != ' ' && line[j] != '\t' {
j++
}
tokens = append(tokens, line[i:j])
i = j
}
}
return tokens
}
// splitList takes a token like "(FLAGS UID)" and returns its inner
// space-separated items — used by FETCH/STORE argument parsing.
func splitList(token string) []string {
inner := strings.TrimPrefix(token, "(")
inner = strings.TrimSuffix(inner, ")")
if inner == "" {
return nil
}
return tokenize(inner)
}
func isList(token string) bool {
return strings.HasPrefix(token, "(") && strings.HasSuffix(token, ")")
}
+150
View File
@@ -0,0 +1,150 @@
// Package imap implements a hand-rolled IMAP server covering the core
// command set (RFC 3501/9051 essentials): CAPABILITY, LOGIN, LOGOUT, NOOP,
// SELECT/EXAMINE, LIST, FETCH, UID FETCH, STORE, UID STORE, SEARCH, EXPUNGE,
// CLOSE, UNSELECT. No third-party IMAP library — stdlib net.Listener plus a
// small hand-written parser for IMAP's atom/quoted-string/literal syntax.
//
// Deferred to a later pass (noted here so the gap is visible, not hidden):
// IDLE, CONDSTORE/QRESYNC, SORT/THREAD, and mailbox CREATE/DELETE/RENAME.
// The core set above is enough for read/flag/delete workflows against an
// existing mailbox, which covers most mail client usage; IDLE (push) and
// folder management are the natural next additions.
package imap
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"sync"
"time"
"gomail/internal/db"
"gomail/internal/mailstore"
"gomail/internal/ratelimit"
)
const (
idleTimeout = 30 * time.Minute // IMAP clients often sit connected much longer than SMTP
maxCommandLine = 8192
)
type Server struct {
database *db.DB
store *mailstore.Store
tlsConf *tls.Config
hostname string
listeners []net.Listener
wg sync.WaitGroup
sessionWG sync.WaitGroup
connLimiter *ratelimit.Limiter // per-IP connections/min
authLimiter *ratelimit.Limiter // per-IP LOGIN failures/min — checked before credential verification
}
func NewServer(database *db.DB, store *mailstore.Store, tlsConf *tls.Config, hostname string, connPerMin, authFailuresPerMin int) *Server {
return &Server{
database: database,
store: store,
tlsConf: tlsConf,
hostname: hostname,
connLimiter: ratelimit.New(connPerMin),
authLimiter: ratelimit.New(authFailuresPerMin),
}
}
// ListenAndServe starts the plain (:143, STARTTLS-capable) and implicit-TLS
// (:993) listeners and blocks until ctx is cancelled or a listener fails.
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("IMAP 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("IMAP accept error", "err", err)
return
}
}
ip := connHost(conn.RemoteAddr())
if !s.connLimiter.Allow(ip) {
slog.Warn("IMAP connection rate limit exceeded, rejecting", "ip", ip)
conn.Close()
continue
}
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 IMAP sessions drained cleanly")
case <-time.After(gracePeriod):
slog.Warn("IMAP shutdown grace period expired — some sessions forcibly terminated")
}
}
func (s *Server) closeAll() {
for _, ln := range s.listeners {
ln.Close()
}
s.wg.Wait()
}
// connHost extracts just the IP (no port) from a net.Addr, for use as a
// rate-limiter key.
func connHost(addr net.Addr) string {
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
+205
View File
@@ -0,0 +1,205 @@
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
}
+33
View File
@@ -0,0 +1,33 @@
package imap
import "testing"
func FuzzTokenize(f *testing.F) {
f.Add(`a001 LOGIN user pass`)
f.Add(`a002 SELECT INBOX`)
f.Add(`a003 FETCH 1:* (FLAGS UID)`)
f.Add(`a004 SEARCH UNSEEN`)
f.Add(`a005 STORE 1 +FLAGS (\Seen)`)
f.Add(`a006 LOGIN "quoted user" "quoted pass"`)
f.Add("")
f.Add(`(((((`)
f.Add(`"unterminated`)
f.Add(`a007 LIST "" *`)
f.Add(`a008 UID FETCH 1 (BODY[HEADER])`)
f.Add(`nested (parens (inside (parens)))`)
f.Add("\x00\x01\x02 binary garbage")
f.Add(`"escaped \" quote"`)
f.Fuzz(func(t *testing.T, data string) {
// tokenize runs on every line a connected IMAP client sends, before
// any authentication has necessarily succeeded (e.g. the initial
// CAPABILITY/LOGIN exchange) — so it's exposed to fully untrusted
// network input and must never panic regardless of what's sent.
defer func() {
if r := recover(); r != nil {
t.Fatalf("tokenize panicked on input %q: %v", data, r)
}
}()
tokenize(data)
})
}