519 lines
13 KiB
Go
519 lines
13 KiB
Go
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
|
|
}
|