first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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, ")")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user