Files
gomail/internal/imap/commands.go
T

519 lines
13 KiB
Go
Raw Normal View History

2026-08-09 18:03:09 +01:00
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
}