all repos — postern @ fc287763b9ce6b6f9823e11c0b1173da0446ebc5

Modern mail management

internal/imap/handler.go (view raw)

   1package imap
   2
   3import (
   4	"context"
   5	"database/sql"
   6	"errors"
   7	"fmt"
   8	"log/slog"
   9	"math"
  10	"slices"
  11	"strings"
  12	"time"
  13
  14	"postern/internal/db"
  15	"postern/internal/model"
  16	"postern/internal/persistence"
  17	"postern/internal/policy"
  18
  19	"github.com/emersion/go-imap/v2"
  20	"github.com/emersion/go-imap/v2/imapserver"
  21	"github.com/google/uuid"
  22)
  23
  24type imapSession struct {
  25	db                      *db.DB
  26	conn                    *imapserver.Conn
  27	mTracker                *mailboxTrackerRegistry
  28	persistence             *persistence.Persistence
  29	searchRes               imap.NumSet // RFC 9051 6.4.4.1
  30	sessionTracker          *imapserver.SessionTracker
  31	selectedMailbox         model.Mailbox
  32	selectedMailboxReadOnly bool
  33	user                    model.User
  34	sessionID               string
  35	policyEngine            *policy.Engine
  36}
  37
  38func (i *Server) handleIMAPConn(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
  39	sessionID := uuid.NewString()
  40	slog.Info("handle new IMAP connection", "remote_ip", conn.NetConn().RemoteAddr().String(), "session_id", sessionID)
  41	return &imapSession{
  42		db:              i.db,
  43		conn:            conn,
  44		selectedMailbox: model.Mailbox{},
  45		mTracker:        i.mTracker,
  46		persistence:     i.persistence,
  47		sessionTracker:  nil,
  48		sessionID:       sessionID,
  49		policyEngine:    i.policyEngine,
  50	}, &imapserver.GreetingData{}, nil
  51}
  52
  53func (m *imapSession) Close() error {
  54	slog.Info("close IMAP session", "session_id", m.sessionID)
  55	// Package calls EXPUNGE internally, which leads to a bug in which
  56	// CLOSE emits an error instead of silently closing for read-only SELECTs
  57	if m.sessionTracker != nil {
  58		m.sessionTracker.Close()
  59	}
  60	m.selectedMailbox = model.Mailbox{}
  61	return nil
  62}
  63
  64func (m *imapSession) Login(username, password string) error {
  65	logger := slog.With("session_id", m.sessionID, "cmd", "LOGIN", "username", username)
  66	u, err := m.db.GetUser(context.Background(), username)
  67	if err != nil {
  68		if errors.Is(err, sql.ErrNoRows) {
  69			logger.Info("user not found")
  70		} else {
  71			logger.Error("getting user", "error", err)
  72		}
  73		return imapserver.ErrAuthFailed
  74	}
  75
  76	err = u.VerifyPassword([]byte(password))
  77	if err != nil {
  78		slog.Info("invalid password", "username", username)
  79		return imapserver.ErrAuthFailed
  80	}
  81
  82	logger.Info("logged in", "user_id", u.ID)
  83	m.user = u
  84	return nil
  85
  86}
  87
  88// Select and Examine are identical besides read-only (https://www.ietf.org/rfc/rfc9051.html#name-examine-command)
  89func (m *imapSession) Select(mailboxName string, selectOpt *imap.SelectOptions) (*imap.SelectData, error) {
  90	logger := slog.With("session_id", m.sessionID, "cmd", "SELECT", "user_id", m.user.ID)
  91	// INBOX is case-insensitive
  92	if strings.EqualFold(mailboxName, string(model.MailboxINBOX)) {
  93		mailboxName = string(model.MailboxINBOX)
  94	}
  95
  96	logger.Info("start SELECT", "mailbox", mailboxName, "read_only", selectOpt.ReadOnly)
  97
  98	userMailbox, err := m.db.GetUserMailbox(context.Background(), m.user.ID, mailboxName)
  99	if err != nil {
 100		if errors.Is(err, db.ErrMailboxNotFound) {
 101			return nil, errMailboxNotExist
 102		}
 103		logger.Error("getting mailbox", "error", err)
 104		return nil, err
 105	}
 106
 107	if m.selectedMailbox.ID != userMailbox.ID && m.sessionTracker != nil {
 108		m.sessionTracker.Close()
 109		m.sessionTracker = nil
 110	}
 111
 112	m.sessionTracker = m.mTracker.getMailboxTracker(m.user.ID, userMailbox.ID, userMailbox.NumMessages).NewSession()
 113	m.selectedMailbox = userMailbox
 114	m.selectedMailboxReadOnly = selectOpt.ReadOnly
 115
 116	activeFlags, err := m.db.GetMailboxFlags(context.Background(), m.selectedMailbox.ID)
 117	if err != nil {
 118		logger.Error("getting mailbox flags", "error", err)
 119		return nil, err
 120	}
 121
 122	// RFC 9051: Upon successful completion of a SELECT or an EXAMINE command (after the tagged OK response),
 123	// the current search result variable is reset to the empty sequence.
 124	m.searchRes = imap.UIDSet{}
 125
 126	logger.Info("end SELECT")
 127	return &imap.SelectData{
 128		NumMessages: userMailbox.NumMessages,
 129		NumRecent:   0, // (legacy, send 0)
 130		UIDValidity: userMailbox.UIDValidity,
 131		UIDNext:     imap.UID(userMailbox.UIDNext),
 132		PermanentFlags: []imap.Flag{
 133			imap.FlagWildcard, imap.FlagFlagged, imap.FlagSeen,
 134			imap.FlagDeleted, imap.FlagAnswered, imap.FlagDraft,
 135		},
 136		Flags: toIMAPFlags(activeFlags),
 137		List: &imap.ListData{
 138			Attrs:   getMailboxAttr(userMailbox),
 139			Delim:   '/',
 140			Mailbox: mailboxName,
 141			Status: &imap.StatusData{
 142				Mailbox:        mailboxName,
 143				NumMessages:    new(userMailbox.NumMessages),
 144				NumRecent:      new(uint32), // obsolete
 145				UIDNext:        imap.UID(userMailbox.UIDNext),
 146				UIDValidity:    userMailbox.UIDValidity,
 147				NumUnseen:      new(uint32(userMailbox.NumUnseen)),
 148				NumDeleted:     new(uint32(userMailbox.NumDeleted)),
 149				Size:           new(userMailbox.Size),
 150				AppendLimit:    new(uint32),
 151				DeletedStorage: new(int64),
 152				HighestModSeq:  uint64(userMailbox.HighestModSeq),
 153			},
 154		},
 155	}, nil
 156}
 157
 158func (m *imapSession) Create(mailbox string, _ *imap.CreateOptions) error {
 159	logger := slog.With("session_id", m.sessionID, "cmd", "CREATE", "user_id", m.user.ID)
 160	logger.Info("start CREATE", "mailbox", mailbox)
 161	if strings.EqualFold(mailbox, string(model.MailboxINBOX)) {
 162		logger.Info("creating INBOX not allowed")
 163		return &imap.Error{
 164			Type: imap.StatusResponseTypeNo,
 165			Code: imap.ResponseCodeAlreadyExists,
 166			Text: "Mailbox INBOX always exists",
 167		}
 168	}
 169
 170	// per RFC 9051, 7.3.1, all mailbox attributes are optional.
 171	// Furthermore, they are user-specific in case of a shared mailbox (for example \Sent has per-user meaning).
 172	// Ignore special attributes now because we have pre-defined mailboxes already, which should be un-deletable.
 173
 174	// mailbox should exist under the name without the trailing delimiter
 175	mailbox, _ = strings.CutPrefix(mailbox, "/")
 176	mailbox, _ = strings.CutSuffix(mailbox, "/")
 177
 178	// This can be a toctou, but we have a unique constraint on mailbox names
 179	mbID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox)
 180	if err != nil && !errors.Is(err, db.ErrMailboxNotFound) {
 181		logger.Error("getting mailbox", "error", err)
 182		return errInternalServerError
 183	}
 184	if mbID != 0 {
 185		logger.Info("cannot create already existing mailbox", "mailbox", mailbox)
 186		return &imap.Error{
 187			Type: imap.StatusResponseTypeNo,
 188			Code: imap.ResponseCodeNonExistent,
 189			Text: "Mailbox already exists",
 190		}
 191	}
 192
 193	err = m.db.CreateMailbox(context.Background(), m.user.ID, mailbox)
 194	if err != nil {
 195		logger.Error("creating mailbox", "error", err)
 196		return errInternalServerError
 197	}
 198	logger.Info("end CREATE")
 199	return nil
 200}
 201
 202func (m *imapSession) Delete(mailbox string) error {
 203	logger := slog.With("session_id", m.sessionID, "cmd", "DELETE", "user_id", m.user.ID)
 204	logger.Info("start DELETE", "mailbox", mailbox)
 205	// We do not allow to delete default mailboxes because we need their special use present
 206	for _, dmb := range model.DefaultMailboxes() {
 207		if string(dmb.Name) == mailbox {
 208			logger.Info("denied deleting default mailbox", "mailbox", mailbox)
 209			return &imap.Error{
 210				Type: imap.StatusResponseTypeNo,
 211				Code: imap.ResponseCodeCannot,
 212				Text: "Deleting preset mailbox is not allowed",
 213			}
 214		}
 215	}
 216
 217	mailboxID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox)
 218	if errors.Is(err, db.ErrMailboxNotFound) {
 219		return errMailboxNotExist
 220	}
 221	if err != nil {
 222		logger.Error("getting mailbox ID", "error", err)
 223		return errInternalServerError
 224	}
 225
 226	// deleting messages is left to the "prune" CLI and not part of this operation
 227	err = m.db.DeleteMailbox(context.Background(), m.user.ID, mailbox)
 228	if err != nil {
 229		logger.Error("deleting mailbox", "error", err)
 230		return errInternalServerError
 231	}
 232
 233	m.mTracker.remove(m.user.ID, mailboxID)
 234	logger.Info("end DELETE", "mailbox", mailbox)
 235	return nil
 236}
 237
 238func (m *imapSession) Rename(mailbox, newName string, _ *imap.RenameOptions) error {
 239	logger := slog.With("session_id", m.sessionID, "cmd", "RENAME", "user_id", m.user.ID)
 240	logger.Info("start RENAME", "mailbox", mailbox, "new_mailbox_name", newName)
 241
 242	if mailbox == string(model.MailboxINBOX) {
 243		// Special case per RFC 6.3.6:
 244		// Renaming INBOX is permitted and does not result in a tagged BAD response, and it has special behavior:
 245		// It moves all messages in INBOX to a new mailbox with the given name, leaving INBOX empty. If the server
 246		// implementation supports inferior hierarchical names of INBOX, these are unaffected by a rename of INBOX.
 247		err := m.db.CreateAndMoveAllMessagesFromINBOXToMailbox(context.Background(), m.user.ID, newName)
 248		if err != nil {
 249			logger.Error("rename INBOX", "error", err)
 250			return errInternalServerError
 251		}
 252
 253		logger.Info("end RENAME", "mailbox", mailbox, "new_mailbox_name", newName)
 254		return nil
 255	}
 256
 257	// We do not allow to rename default mailboxes because we need their special use present
 258	for _, dmb := range model.DefaultMailboxes() {
 259		if string(dmb.Name) == mailbox {
 260			logger.Info("denied renaming default mailbox", "mailbox", mailbox)
 261			return &imap.Error{
 262				Type: imap.StatusResponseTypeNo,
 263				Code: imap.ResponseCodeCannot,
 264				Text: "Renaming preset mailbox is not allowed",
 265			}
 266		}
 267	}
 268
 269	// Check if mailbox exists
 270	_, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox)
 271	if errors.Is(err, db.ErrMailboxNotFound) {
 272		logger.Info("mailbox does not exist", "mailbox", mailbox)
 273		return errMailboxNotExist
 274	}
 275	if err != nil {
 276		logger.Error("getting mailbox ID", "error", err)
 277		return errInternalServerError
 278	}
 279
 280	// Check if new mailbox name already exists
 281	newMailboxID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, newName)
 282	if newMailboxID != 0 {
 283		slog.Info("new mailbox name already exists", "new_mailbox_name", newMailboxID)
 284		return &imap.Error{
 285			Type: imap.StatusResponseTypeNo,
 286			Code: imap.ResponseCodeAlreadyExists,
 287			Text: "Mailbox with the same name already exists",
 288		}
 289	}
 290	if err != nil && !errors.Is(err, db.ErrMailboxNotFound) {
 291		logger.Error("getting mailbox ID", "error", err)
 292		return errInternalServerError
 293	}
 294
 295	err = m.db.RenameMailbox(context.Background(), m.user.ID, mailbox, newName)
 296	if err != nil {
 297		logger.Error("renaming mailbox", "error", err)
 298		return errInternalServerError
 299	}
 300	logger.Info("end RENAME", "mailbox", mailbox, "new_mailbox_name", newName)
 301	return nil
 302}
 303
 304func (m *imapSession) Subscribe(mb string) error {
 305	logger := slog.With("session_id", m.sessionID, "cmd", "SUBSCRIBE", "user_id", m.user.ID)
 306	logger.Info("start SUBSCRIBE", "mailbox", mb)
 307	mailboxID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mb)
 308	if err != nil {
 309		if errors.Is(err, db.ErrMailboxNotFound) {
 310			return errMailboxNotExist
 311		}
 312		logger.Error("getting mailbox ID", "error", err)
 313		return errInternalServerError
 314	}
 315	err = m.db.Subscribe(context.Background(), m.user.ID, mailboxID, mb)
 316	if err != nil {
 317		logger.Error("subscribe mailbox", "error", err)
 318		return errInternalServerError
 319	}
 320	logger.Info("end SUBSCRIBE", "mailbox", mb)
 321	return nil
 322}
 323
 324func (m *imapSession) Unsubscribe(mb string) error {
 325	logger := slog.With("session_id", m.sessionID, "cmd", "UNSUBSCRIBE", "user_id", m.user.ID)
 326	logger.Info("start UNSUBSCRIBE", "mailbox", mb)
 327
 328	// We do not allow to unsubscribe from default mailboxes because we need their special use present
 329	for _, dmb := range model.DefaultMailboxes() {
 330		if string(dmb.Name) == mb {
 331			logger.Info("denied unsubscribing default mailbox", "mailbox", mb)
 332			return &imap.Error{
 333				Type: imap.StatusResponseTypeNo,
 334				Code: imap.ResponseCodeCannot,
 335				Text: "Unsubscribing preset mailbox is not allowed",
 336			}
 337		}
 338	}
 339
 340	err := m.db.Unsubscribe(context.Background(), m.user.ID, mb)
 341	if err != nil {
 342		logger.Error("unsubscribe mailbox", "error", err)
 343		return errInternalServerError
 344	}
 345	logger.Info("end UNSUBSCRIBE", "mailbox", mb)
 346	return nil
 347}
 348
 349func (m *imapSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
 350	logger := slog.With("session_id", m.sessionID, "cmd", "LIST", "user_id", m.user.ID)
 351	logger.Info("start LIST", "ref", ref, "patterns", patterns, "options", options)
 352
 353	// Special case LIST "" "" - mainly for the client to get the folder hierarchy delimiter
 354	if ref == "" && len(patterns) == 0 {
 355		err := w.WriteList(&imap.ListData{
 356			Attrs:   []imap.MailboxAttr{imap.MailboxAttrNoSelect},
 357			Delim:   '/',
 358			Mailbox: "",
 359		})
 360		if err != nil {
 361			logger.Error("listing mailboxes", "error", err)
 362			return errInternalServerError
 363		}
 364		logger.Info("end LIST \"\" \"\"")
 365		return nil
 366	}
 367
 368	// LIST (SUBSCRIBED)
 369	if options.SelectSubscribed {
 370		subscribed, err := m.db.ListSubscribed(context.Background(), m.user.ID)
 371		if err != nil {
 372			logger.Error("listSubscribed", "error", err)
 373			return errInternalServerError
 374		}
 375
 376		for mb, exists := range subscribed {
 377			for _, pattern := range patterns {
 378				// We MUST pattern match subscribed mailboxes
 379				if imapserver.MatchList(mb.Name, '/', ref, pattern) {
 380					attr := getMailboxAttr(mb)
 381
 382					if !exists {
 383						attr = append(attr, imap.MailboxAttrNonExistent, imap.MailboxAttrNoSelect)
 384					}
 385
 386					err = w.WriteList(&imap.ListData{
 387						Attrs:   attr,
 388						Delim:   '/',
 389						Mailbox: mb.Name,
 390					})
 391					if err != nil {
 392						logger.Error("listing mailboxes", "error", err)
 393						return errInternalServerError
 394					}
 395					// Matched this mailbox, don't write it again if it matches a 2nd pattern
 396					break
 397				}
 398			}
 399		}
 400		logger.Info("end LIST subscribed")
 401		return nil
 402	}
 403
 404	// Standard LIST "" "*"
 405	mailboxes, err := m.db.GetUserMailboxes(context.Background(), m.user.ID)
 406	if err != nil {
 407		logger.Error("get user mailboxes", "error", err)
 408		return errInternalServerError
 409	}
 410
 411	hasChildren := calcHasChildren(mailboxes)
 412
 413	for _, mb := range mailboxes {
 414		for _, pattern := range patterns {
 415			match := imapserver.MatchList(mb.Name, '/', ref, pattern)
 416			logger.Debug("LIST imapserver.MatchList", "mailbox", mb.Name, "ref", ref, "pattern", pattern, "match", match)
 417
 418			if match {
 419				attr := getMailboxAttr(mb)
 420
 421				// RFC 9051: MAY return children attribute even if it was not requested
 422				if hasChildren[mb.Name] {
 423					attr = append(attr, imap.MailboxAttrHasChildren)
 424				} else {
 425					attr = append(attr, imap.MailboxAttrHasNoChildren)
 426				}
 427
 428				// 2. Pass the addresses (&) of those local variables
 429				numMessages := mb.NumMessages
 430				numUnseen := uint32(mb.NumUnseen)
 431				numDeleted := uint32(mb.NumDeleted)
 432				size := mb.Size
 433				err := w.WriteList(&imap.ListData{
 434					Attrs:     attr,
 435					Delim:     '/',
 436					Mailbox:   mb.Name,
 437					ChildInfo: nil, // Should be returned on RECURSIVEMATCH
 438					Status: &imap.StatusData{
 439						Mailbox:        mb.Name,
 440						UIDNext:        imap.UID(mb.UIDNext),
 441						UIDValidity:    mb.UIDValidity,
 442						HighestModSeq:  uint64(mb.HighestModSeq),
 443						NumMessages:    &numMessages,
 444						NumUnseen:      &numUnseen,
 445						NumDeleted:     &numDeleted,
 446						Size:           &size,
 447						NumRecent:      new(uint32),
 448						AppendLimit:    new(uint32),
 449						DeletedStorage: new(int64),
 450					},
 451				})
 452
 453				if err != nil {
 454					logger.Error("listing mailboxes", "error", err)
 455					return errInternalServerError
 456				}
 457
 458				logger.Debug("WriteList", "mailbox", mb.Name, "attr", attr)
 459
 460				// Matched this mailbox, don't write it again
 461				break
 462			}
 463		}
 464	}
 465
 466	logger.Info("end LIST")
 467	return nil
 468}
 469
 470func calcHasChildren(mailboxes []model.Mailbox) map[string]bool {
 471	hasChildren := make(map[string]bool)
 472	for _, mb := range mailboxes {
 473		s := strings.Split(mb.Name, "/")
 474		if len(s) > 1 {
 475			hasChildren[strings.Join(s[:len(s)-1], "/")] = true
 476		}
 477	}
 478	return hasChildren
 479}
 480
 481func (m *imapSession) Status(mailboxName string, _ *imap.StatusOptions) (*imap.StatusData, error) {
 482	logger := slog.With("session_id", m.sessionID, "cmd", "STATUS", "user_id", m.user.ID)
 483	logger.Info("start STATUS", "mailbox_name", mailboxName)
 484	userMailboxes, err := m.db.GetUserMailboxes(context.Background(), m.user.ID)
 485	if err != nil {
 486		logger.Error("get user mailboxes", "error", err)
 487		return &imap.StatusData{}, errInternalServerError
 488	}
 489
 490	var statusBox model.Mailbox
 491	found := false
 492	for _, mb := range userMailboxes {
 493		if mb.Name == mailboxName {
 494			statusBox = mb
 495			found = true
 496			break
 497		}
 498	}
 499
 500	if !found {
 501		logger.Info("mailbox not found", "name", mailboxName)
 502		return &imap.StatusData{}, errMailboxNotExist
 503	}
 504
 505	logger.Info("end STATUS", "mailbox_name", mailboxName)
 506	return &imap.StatusData{
 507		Mailbox:        mailboxName,
 508		NumMessages:    new(statusBox.NumMessages),
 509		NumRecent:      new(uint32), // obsolete
 510		UIDNext:        imap.UID(statusBox.UIDNext),
 511		UIDValidity:    statusBox.UIDValidity,
 512		NumUnseen:      new(uint32(statusBox.NumUnseen)),
 513		NumDeleted:     new(uint32(statusBox.NumDeleted)),
 514		Size:           new(statusBox.Size),
 515		AppendLimit:    new(uint32),
 516		DeletedStorage: new(int64),
 517		HighestModSeq:  uint64(statusBox.HighestModSeq),
 518	}, nil
 519}
 520
 521func (m *imapSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
 522	logger := slog.With("session_id", m.sessionID, "cmd", "APPEND", "user_id", m.user.ID)
 523	logger.Info("start APPEND", "mailbox", mailbox)
 524	// If the destination mailbox does not exist, a server MUST return an error and MUST NOT automatically create the
 525	// mailbox. Unless it is certain that the destination mailbox cannot be created, the server MUST send the response
 526	// code "[TRYCREATE]" as the prefix of the text of the tagged NO response. This gives a hint to the client that it
 527	// can attempt a CREATE command and retry the APPEND if the CREATE is successful.
 528	mailboxID, uidValidity, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox)
 529	if err != nil {
 530		if errors.Is(err, db.ErrMailboxNotFound) {
 531			logger.Info("mailbox not found", "mailbox", mailbox)
 532			return nil, errMailboxNotExist
 533		}
 534		logger.Error("get mailbox", "error", err)
 535		return nil, errInternalServerError
 536	}
 537
 538	for _, f := range options.Flags {
 539		if !isAllowedFlag(f) {
 540			logger.Info("flag not allowed", "flag", f)
 541			return nil, errBadFlag
 542		}
 543	}
 544
 545	internalTime := time.Now()
 546	if !options.Time.IsZero() {
 547		internalTime = options.Time
 548	}
 549
 550	fileName, size, parsedMsg, err := m.persistence.WriteBlobMessage(r)
 551	if err != nil {
 552		logger.Error("write blob", "error", err)
 553		return nil, errInternalServerError
 554	}
 555
 556	logger.Info("wrote file", "filename", fileName, "size", size)
 557	uid, err := m.db.AppendMessage(context.Background(), mailboxID, m.user.ID, fileName, size, internalTime.UTC().Format(time.RFC3339), fromIMAPFlags(options.Flags), parsedMsg)
 558	if err != nil {
 559		logger.Error("write message metadata", "error", err)
 560		return nil, errInternalServerError
 561	}
 562
 563	// In the case of a mailbox that has permissions set so that the client can APPEND to the mailbox, but not SELECT or
 564	// EXAMINE it, the server MUST NOT send an APPENDUID response code as it would disclose information about the mailbox.
 565
 566	mb, err := m.db.GetUserMailbox(context.Background(), m.user.ID, mailbox)
 567	if err != nil {
 568		logger.Error("get user mailbox", "error", err)
 569		return nil, errInternalServerError
 570	}
 571
 572	t := m.mTracker.get(m.user.ID, mailboxID)
 573	if t != nil {
 574		t.QueueNumMessages(mb.NumMessages)
 575	}
 576
 577	logger.Info("end APPEND", "mailbox", mailbox)
 578	return &imap.AppendData{
 579		UID:         imap.UID(uid),
 580		UIDValidity: uidValidity,
 581	}, nil
 582}
 583
 584func (m *imapSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
 585	logger := slog.With("session_id", m.sessionID, "cmd", "POLL", "user_id", m.user.ID)
 586	logger.Debug("start POLL")
 587	if m.sessionTracker == nil {
 588		logger.Debug("session tracker not initialized")
 589		return nil
 590	}
 591	err := m.sessionTracker.Poll(w, allowExpunge)
 592	if err != nil {
 593		logger.Error("poll session tracker", "error", err)
 594		return errInternalServerError
 595	}
 596	logger.Debug("end POLL")
 597	return nil
 598}
 599
 600func (m *imapSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
 601	logger := slog.With("session_id", m.sessionID, "cmd", "IDLE", "user_id", m.user.ID)
 602	logger.Debug("start IDLE")
 603	if m.sessionTracker == nil {
 604		logger.Debug("session tracker not initialized")
 605		return nil
 606	}
 607	err := m.sessionTracker.Idle(w, stop)
 608	if err != nil {
 609		logger.Error("idle session tracker", "error", err)
 610		return errInternalServerError
 611	}
 612	logger.Debug("end IDLE")
 613	return nil
 614}
 615
 616func (m *imapSession) Unselect() error {
 617	logger := slog.With("session_id", m.sessionID, "cmd", "UNSELECT", "user_id", m.user.ID)
 618	logger.Info("start UNSELECT")
 619	m.selectedMailbox = model.Mailbox{}
 620	m.sessionTracker.Close()
 621	m.sessionTracker = nil
 622	logger.Info("end UNSELECT")
 623	return nil
 624}
 625
 626// removeFromSearchResult returns res with the given uid/seq removed.
 627// If res is not a set type we know about, it is returned unchanged.
 628func removeFromSearchResult(res imap.NumSet, uid, seq uint32) imap.NumSet {
 629	switch s := res.(type) {
 630	case imap.UIDSet:
 631		if !s.Contains(imap.UID(uid)) {
 632			return s
 633		}
 634		out := imap.UIDSetNum()
 635		nums, _ := s.Nums()
 636		for _, n := range nums {
 637			if n != imap.UID(uid) {
 638				out.AddNum(n) // FIX: was adding the deleted uid
 639			}
 640		}
 641		return out
 642	case imap.SeqSet:
 643		if !s.Contains(seq) {
 644			return s
 645		}
 646		out := imap.SeqSetNum()
 647		nums, _ := s.Nums()
 648		for _, n := range nums {
 649			if n != seq {
 650				out.AddNum(n) // FIX: was adding the deleted seq
 651			}
 652		}
 653		return out
 654	}
 655	return res
 656}
 657
 658// Expunge SELECTED state
 659func (m *imapSession) Expunge(_ *imapserver.ExpungeWriter, uids *imap.UIDSet) error {
 660	ctx := context.Background()
 661
 662	logger := slog.With("session_id", m.sessionID, "cmd", "EXPUNGE", "user_id", m.user.ID)
 663	logger.InfoContext(ctx, "start EXPUNGE", "uids", uids)
 664	if m.selectedMailboxReadOnly {
 665		logger.InfoContext(ctx, "selected mailbox is read-only")
 666		// This will either break Close() or Expunge() because CLOSE expects a clean OK, EXPUNGE below error
 667		return errMailboxReadOnly
 668	}
 669
 670	if m.selectedMailbox.ID == -1 {
 671		logger.InfoContext(ctx, "no mailbox selected")
 672		return errNoMailboxSelected
 673	}
 674
 675	// UID EXPUNGE restricts removal to the given set. A nil or empty set means
 676	// "expunge every \Deleted message" (plain EXPUNGE semantics).
 677	uidFilter := func(uid imap.UID) bool { return true }
 678	if uids != nil {
 679		if nums, _ := uids.Nums(); len(nums) > 0 {
 680			uidFilter = func(uid imap.UID) bool { return uids.Contains(uid) }
 681		}
 682	}
 683
 684	toBeDeleted, err := m.db.GetAllFlaggedDeletedMessages(ctx, m.selectedMailbox.ID)
 685	if err != nil {
 686		logger.ErrorContext(ctx, "get flagged deleted messages", "error", err)
 687		return errInternalServerError
 688	}
 689	logger.InfoContext(ctx, "candidates", "mailbox_id", m.selectedMailbox.ID, "uids", toBeDeleted)
 690
 691	// Resolve all seqnums BEFORE any deletion, then delete high-to-low.
 692	// RFC 9051: The UID EXPUNGE command permanently removes all messages that have both the \Deleted flag set and a UID
 693	// that is included in the specified sequence set from the currently selected mailbox. If a message either does not
 694	// have the \Deleted flag set or has a UID that is not included in the specified sequence set, it is not affected.
 695	type expunge struct{ uid, seq uint32 }
 696	list := make([]expunge, 0, len(toBeDeleted))
 697	for _, uid := range toBeDeleted {
 698		if !uidFilter(imap.UID(uid)) {
 699			continue
 700		}
 701		seq, err := m.db.UIDToServerSeq(ctx, m.selectedMailbox.ID, uid)
 702		if err != nil {
 703			logger.ErrorContext(ctx, "UID to server seq", "error", err)
 704			return errInternalServerError
 705		}
 706		list = append(list, expunge{uid, seq})
 707	}
 708	slices.SortFunc(list, func(i, j expunge) int { return int(j.seq) - int(i.seq) }) // descending
 709
 710	tracker := m.mTracker.get(m.user.ID, m.selectedMailbox.ID)
 711	for _, seqNumUID := range list {
 712		err = m.db.DeleteMessageFromMailbox(ctx, m.selectedMailbox.ID, seqNumUID.uid)
 713		if err != nil {
 714			logger.ErrorContext(ctx, "delete message from mailbox", "error", err)
 715			return errInternalServerError
 716		}
 717
 718		if tracker != nil {
 719			tracker.QueueExpunge(seqNumUID.seq)
 720		}
 721
 722		m.searchRes = removeFromSearchResult(m.searchRes, seqNumUID.uid, seqNumUID.seq)
 723	}
 724
 725	logger.InfoContext(ctx, "end EXPUNGE")
 726	return nil
 727}
 728
 729// Search SELECTED State
 730func (m *imapSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
 731	logger := slog.With("session_id", m.sessionID, "cmd", "SEARCH", "user_id", m.user.ID)
 732	logger.Info("start SEARCH", "criteria", criteria, "options", options)
 733	allMailboxMessages, err := m.db.GetAllMailboxMessages(context.Background(), m.user.ID, m.selectedMailbox.ID)
 734	if err != nil {
 735		logger.Error("Unable to retrieve messages for searching", "error", err)
 736		return nil, errInternalServerError
 737	}
 738
 739	uidSet := make([]imap.UID, 0)
 740	uidSetCount := 0 // leads to wrong results with len(uidSet)
 741	for _, msg := range allMailboxMessages {
 742		if m.evalSearchCriteria(criteria, msg, m.sessionTracker, m.persistence) {
 743			uidSet = append(uidSet, imap.UID(msg.UID))
 744			uidSetCount++
 745		}
 746	}
 747
 748	slices.Sort(uidSet)
 749	uidMin := 0
 750	if len(uidSet) > 0 {
 751		uidMin = int(uidSet[0])
 752	}
 753	uidMax := 0
 754	if len(uidSet) > 0 {
 755		uidMax = int(uidSet[len(uidSet)-1])
 756	}
 757	uidSetNum := imap.UIDSetNum(uidSet...)
 758
 759	// Combination of Result Option	"$" Marker Value
 760	//SAVE MIN		->	MIN
 761	//SAVE MAX		->	MAX
 762	//SAVE MIN MAX	->	MIN & MAX
 763	//SAVE * [m]	->	all found messages
 764	if options.ReturnSave {
 765		m.searchRes = uidSetNum
 766
 767		if options.ReturnMin {
 768			m.searchRes = imap.UIDSetNum(imap.UID(uint32(uidMin)))
 769		}
 770
 771		if options.ReturnMax {
 772			m.searchRes = imap.UIDSetNum(imap.UID(uint32(uidMax)))
 773		}
 774
 775		if options.ReturnMin && options.ReturnMax {
 776			n := imap.UIDSetNum(imap.UID(uint32(uidMin)))
 777			n.AddNum(imap.UID(uint32(uidMax)))
 778			m.searchRes = n
 779		}
 780	}
 781
 782	if kind == imapserver.NumKindUID {
 783		res := &imap.SearchData{
 784			All:   uidSetNum,
 785			Count: uint32(uidSetCount),
 786			Min:   uint32(uidMin),
 787			Max:   uint32(uidMax),
 788		}
 789		logger.Info("end SEARCH", "search_result", res)
 790		return res, nil
 791	}
 792
 793	if kind == imapserver.NumKindSeq {
 794		out := imap.SeqSetNum()
 795		seqNumMin := uint32(math.MaxUint32)
 796		seqNumMax := uint32(0)
 797		seqNumCount := 0
 798		num, _ := uidSetNum.Nums()
 799		for _, n := range num {
 800			serverSeq, err := m.db.UIDToServerSeq(context.Background(), m.selectedMailbox.ID, uint32(n))
 801			if err != nil {
 802				logger.Error("Unable to retrieve server seq", "error", err)
 803				return nil, errInternalServerError
 804			}
 805			seqNumCount++
 806			out.AddNum(serverSeq)
 807			if serverSeq < seqNumMin {
 808				seqNumMin = serverSeq
 809			}
 810			if serverSeq > seqNumMax {
 811				seqNumMax = serverSeq
 812			}
 813		}
 814		res := &imap.SearchData{
 815			All:   out,
 816			Count: uint32(seqNumCount),
 817			Min:   seqNumMin,
 818			Max:   seqNumMax,
 819		}
 820		logger.Info("end SEARCH", "search_result", res)
 821		return res, nil
 822	}
 823
 824	return nil, errInternalServerError
 825}
 826
 827// Fetch SELECTED State
 828func (m *imapSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
 829	logger := slog.With("session_id", m.sessionID, "cmd", "FETCH", "user_id", m.user.ID)
 830	logger.Info("start FETCH", "num_set", numSet, "options", options)
 831	var outMessages []model.Message
 832	var err error
 833
 834	sourceUIDs, err := m.resolveUIDs(context.Background(), numSet)
 835	if err != nil {
 836		if errors.Is(err, errDBSequenceOutOfRange) {
 837			logger.Info("sequence out of range")
 838			return errSequenceOutOfRange
 839		}
 840		logger.Error("unable to resolve UIDs", "error", err)
 841		return errInternalServerError
 842	}
 843
 844	// getMailboxMessagesByUID inherently filters out non-existing UIDs
 845	// because it queries by mailboxID and the resolved sourceUIDs.
 846	outMessages, err = m.db.GetMailboxMessagesByUID(context.Background(), m.user.ID, m.selectedMailbox.ID, sourceUIDs)
 847	if err != nil {
 848		logger.Error("unable to fetch mailbox messages", "error", err)
 849		return errInternalServerError
 850	}
 851
 852	// TODO but more on a global scale than just here:
 853	// seq-number      = nz-number / "*"
 854	//                    ; message sequence number (COPY, FETCH, STORE
 855	//                    ; commands) or unique identifier (UID COPY,
 856	//                    ; UID FETCH, UID STORE commands).
 857	//                    ; * represents the largest number in use.  In
 858	//                    ; the case of message sequence numbers, it is
 859	//                    ; the number of messages in a non-empty mailbox.
 860	//                    ; In the case of unique identifiers, it is the
 861	//                    ; unique identifier of the last message in the
 862	//                    ; mailbox or, if the mailbox is empty, the
 863	//                    ; mailbox's current UIDNEXT value.
 864	//                    ; The server should respond with a tagged BAD
 865	//                    ; response to a command that uses a message
 866	//                    ; sequence number greater than the number of
 867	//                    ; messages in the selected mailbox.  This
 868	//                    ; includes "*" if the selected mailbox is empty.
 869	for _, o := range outMessages {
 870		seqNum, err := m.db.UIDToServerSeq(context.Background(), m.selectedMailbox.ID, o.UID)
 871		if err != nil {
 872			logger.Error("unable to retrieve server seq", "error", err)
 873			return errInternalServerError
 874		}
 875
 876		clientSeqNum := m.sessionTracker.EncodeSeqNum(seqNum)
 877		if clientSeqNum == 0 { // message does not exist from client pov
 878			continue
 879		}
 880
 881		mw := w.CreateMessage(clientSeqNum)
 882		if options.UID {
 883			mw.WriteUID(imap.UID(o.UID))
 884		}
 885
 886		if options.InternalDate {
 887			t, _ := time.Parse(time.RFC3339, o.InternalDate)
 888			mw.WriteInternalDate(t)
 889		}
 890
 891		if options.RFC822Size {
 892			mw.WriteRFC822Size(o.RFC822Size)
 893		}
 894
 895		if options.Envelope {
 896			fromAddresses, err := parseAddressList(o.EnvelopeFrom.String)
 897			if err != nil {
 898				logger.Error("unable to parse envelope FROM addresses", "error", err)
 899				return errInternalServerError
 900			}
 901			senderAddresses, err := parseAddressList(o.EnvelopeSender.String)
 902			if err != nil {
 903				logger.Error("unable to parse envelope SENDER addresses", "error", err)
 904				return errInternalServerError
 905			}
 906
 907			// RFC: If the Sender or Reply-To header fields are absent in the [RFC5322] header, or are present but empty,
 908			// the server sets the corresponding member of the envelope to be the same value as the from member
 909			// (the client is not expected to know how to do this).
 910			if len(senderAddresses) == 0 {
 911				senderAddresses = fromAddresses
 912			}
 913
 914			replyToAddresses, err := parseAddressList(o.EnvelopeReplyTo.String)
 915			if err != nil {
 916				logger.Error("unable to parse envelope REPLY TO addresses", "error", err)
 917				return errInternalServerError
 918			}
 919			if len(replyToAddresses) == 0 {
 920				replyToAddresses = fromAddresses
 921			}
 922
 923			toAddresses, err := parseAddressList(o.EnvelopeTo.String)
 924			if err != nil {
 925				logger.Error("unable to parse envelope TO addresses", "error", err)
 926				return errInternalServerError
 927			}
 928			ccAddresses, err := parseAddressList(o.EnvelopeCc.String)
 929			if err != nil {
 930				logger.Error("unable to parse envelope CC addresses", "error", err)
 931				return errInternalServerError
 932			}
 933			bccAddresses, err := parseAddressList(o.EnvelopeBcc.String)
 934			if err != nil {
 935				logger.Error("unable to parse envelope BCC addresses", "error", err)
 936				return errInternalServerError
 937			}
 938
 939			var t time.Time
 940			if o.EnvelopeDate.Valid {
 941				t, _ = time.Parse(time.RFC3339, o.EnvelopeDate.String)
 942			}
 943
 944			// imap-go: The In-Reply-To and Message-ID values contain message identifiers without angle brackets.
 945			inReplyToAddresses := make([]string, 0)
 946			for a := range strings.SplitSeq(o.EnvelopeInReplyTo.String, ",") {
 947				inReplyTo, _ := strings.CutPrefix(a, "<")
 948				inReplyTo, _ = strings.CutSuffix(inReplyTo, ">")
 949				inReplyToAddresses = append(inReplyToAddresses, inReplyTo)
 950			}
 951
 952			messageID, _ := strings.CutPrefix(o.EnvelopeMessageID.String, "<")
 953			messageID, _ = strings.CutSuffix(messageID, ">")
 954
 955			mw.WriteEnvelope(&imap.Envelope{
 956				Date:      t,
 957				Subject:   o.EnvelopeSubject.String,
 958				From:      fromAddresses,
 959				Sender:    senderAddresses,
 960				ReplyTo:   replyToAddresses,
 961				To:        toAddresses,
 962				Cc:        ccAddresses,
 963				Bcc:       bccAddresses,
 964				InReplyTo: inReplyToAddresses,
 965				MessageID: messageID,
 966			})
 967		}
 968
 969		// RFC 9051: The \Seen flag is implicitly set; if this causes the flags to change, they SHOULD be included as
 970		// part of the FETCH responses.
 971		mustShowFlags := false
 972		if options.BodyStructure != nil {
 973			r, err := m.persistence.BlobReader(o.BlobHash)
 974			if err != nil {
 975				logger.Error("unable to read blob", "error", err)
 976				return errInternalServerError
 977			}
 978			bs := imapserver.ExtractBodyStructure(r)
 979			err = r.Close()
 980			if err != nil {
 981				logger.Error("unable to close body structure", "error", err)
 982			}
 983			mw.WriteBodyStructure(bs)
 984
 985			// Honestly not sure if flagging messages as \\Seen here is correct, or only below for BodySection
 986			if !slices.Contains(o.Flags, string(imap.FlagSeen)) {
 987				logger.Info("flag message as \\Seen", "uid", o.UID)
 988				_, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, o.UID, []string{string(imap.FlagSeen)})
 989				if err != nil {
 990					logger.Error("unable to add message flags", "error", err)
 991					return errInternalServerError
 992				}
 993				o.Flags = append(o.Flags, string(imap.FlagSeen))
 994				mustShowFlags = true
 995			}
 996		}
 997
 998		for _, bs := range options.BodySection {
 999			if !bs.Peek && !m.selectedMailboxReadOnly {
1000				if !slices.Contains(o.Flags, string(imap.FlagSeen)) {
1001					logger.Info("flag message as \\Seen", "uid", o.UID)
1002					_, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, o.UID, []string{string(imap.FlagSeen)})
1003					if err != nil {
1004						logger.Error("unable to add message flags", "error", err)
1005						return errInternalServerError
1006					}
1007					o.Flags = append(o.Flags, string(imap.FlagSeen))
1008					mustShowFlags = true
1009				}
1010			}
1011			r, err := m.persistence.BlobReader(o.BlobHash)
1012			if err != nil {
1013				logger.Error("unable to read blob", "error", err)
1014				return errInternalServerError
1015			}
1016			ebs := imapserver.ExtractBodySection(r, bs)
1017			err = r.Close()
1018			if err != nil {
1019				logger.Error("unable to close blob reader", "error", err)
1020			}
1021
1022			wc := mw.WriteBodySection(bs, int64(len(ebs)))
1023			_, writeErr := wc.Write(ebs)
1024			closeErr := wc.Close()
1025			if writeErr != nil {
1026				logger.Error("unable to write body section", "error", writeErr)
1027				return errInternalServerError
1028			}
1029			if closeErr != nil {
1030				logger.Error("unable to close body section", "error", closeErr)
1031				return errInternalServerError
1032			}
1033		}
1034
1035		for _, bs := range options.BinarySection {
1036			if !bs.Peek && !m.selectedMailboxReadOnly {
1037				_, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, o.UID, []string{string(imap.FlagSeen)})
1038				if err != nil {
1039					logger.Error("unable to add message flags", "error", err)
1040					return errInternalServerError
1041				}
1042			}
1043			r, err := m.persistence.BlobReader(o.BlobHash)
1044			if err != nil {
1045				logger.Error("unable to read blob", "error", err)
1046				return errInternalServerError
1047			}
1048			buf := imapserver.ExtractBinarySection(r, bs)
1049			err = r.Close()
1050			if err != nil {
1051				logger.Error("unable to close blob reader", "error", err)
1052			}
1053
1054			wc := mw.WriteBinarySection(bs, int64(len(buf)))
1055			_, writeErr := wc.Write(buf)
1056			closeErr := wc.Close()
1057			if writeErr != nil {
1058				logger.Error("unable to write body section", "error", writeErr)
1059				return errInternalServerError
1060			}
1061			if closeErr != nil {
1062				logger.Error("unable to close body section", "error", closeErr)
1063				return errInternalServerError
1064			}
1065		}
1066
1067		for _, bss := range options.BinarySectionSize {
1068			r, err := m.persistence.BlobReader(o.BlobHash)
1069			if err != nil {
1070				logger.Error("unable to read blob", "error", err)
1071				return errInternalServerError
1072			}
1073			n := imapserver.ExtractBinarySectionSize(r, bss)
1074			err = r.Close()
1075			if err != nil {
1076				logger.Error("unable to close blob reader", "error", err)
1077			}
1078			mw.WriteBinarySectionSize(bss, n)
1079		}
1080
1081		// Flags should come last so that the updated \Seen flag is properly communicated
1082		if options.Flags || mustShowFlags {
1083			mw.WriteFlags(toIMAPFlags(o.Flags))
1084		}
1085
1086		if err = mw.Close(); err != nil {
1087			logger.Error("unable to close imap writer", "error", err)
1088			return errInternalServerError
1089		}
1090	}
1091
1092	slog.Info("end FETCH")
1093	return nil
1094}
1095
1096// Store SELECTED state
1097func (m *imapSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, _ *imap.StoreOptions) error {
1098	logger := slog.With("session_id", m.sessionID, "cmd", "STORE", "user_id", m.user.ID)
1099	logger.Info("start STORE", "num_set", numSet, "flags", flags)
1100
1101	if m.selectedMailbox.ID == -1 {
1102		logger.Info("no mailbox selected")
1103		return errNoMailboxSelected
1104	}
1105
1106	if m.selectedMailboxReadOnly {
1107		logger.Info("mailbox selected as read-only")
1108		return errMailboxReadOnly
1109	}
1110
1111	for _, f := range flags.Flags {
1112		if !isAllowedFlag(f) {
1113			logger.Info("flag not allowed", "flag", f)
1114			return errBadFlag
1115		}
1116	}
1117
1118	sourceUIDs, err := m.resolveUIDs(context.Background(), numSet)
1119	if err != nil {
1120		if errors.Is(err, errDBSequenceOutOfRange) {
1121			logger.Info("sequence out of range")
1122			return errSequenceOutOfRange
1123		}
1124		logger.Error("unable to resolve UIDs", "error", err)
1125		return errInternalServerError
1126	}
1127
1128	for _, sourceUID := range sourceUIDs {
1129		// Filter non-existing UIDs to prevent FK constraint errors.go and comply
1130		// with IMAP semantics where STORE on non-existent messages is a no-op.
1131		exists, err := m.db.UIDExists(context.Background(), m.user.ID, sourceUID)
1132		if err != nil {
1133			logger.Error("unable to retrieve UID", "error", err)
1134			return errInternalServerError
1135		}
1136		if !exists {
1137			continue
1138		}
1139
1140		var flagSet []string
1141		switch flags.Op {
1142		case imap.StoreFlagsAdd:
1143			flagSet, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, sourceUID, fromIMAPFlags(flags.Flags))
1144		case imap.StoreFlagsDel:
1145			flagSet, err = m.db.DeleteMessageFlags(context.Background(), m.selectedMailbox.ID, sourceUID, fromIMAPFlags(flags.Flags))
1146		case imap.StoreFlagsSet:
1147			flagSet, err = m.db.SetMessageFlags(context.Background(), m.selectedMailbox.ID, sourceUID, fromIMAPFlags(flags.Flags))
1148		default:
1149			logger.Error("unknown flag op", "flag", flags.Op)
1150			return &imap.Error{
1151				Type: imap.StatusResponseTypeBad,
1152				Code: imap.ResponseCodeClientBug,
1153				Text: fmt.Sprintf("Unknown store flags: %v", flags.Op),
1154			}
1155		}
1156
1157		if err != nil {
1158			logger.Error("unable to store flags", "error", err)
1159			return errInternalServerError
1160		}
1161
1162		seqNum, err := m.db.UIDToServerSeq(context.Background(), m.selectedMailbox.ID, sourceUID)
1163		if err != nil {
1164			logger.Error("unable to convert to server seq", "error", err)
1165			return errInternalServerError
1166		}
1167
1168		imapFlags := toIMAPFlags(flagSet)
1169
1170		if !flags.Silent {
1171			fetchWriter := w.CreateMessage(m.sessionTracker.EncodeSeqNum(seqNum))
1172			fetchWriter.WriteFlags(imapFlags)
1173			err = fetchWriter.Close()
1174			if err != nil {
1175				logger.Error("unable to close imap writer", "error", err)
1176				return errInternalServerError
1177			}
1178		}
1179
1180		m.mTracker.get(m.user.ID, m.selectedMailbox.ID).QueueMessageFlags(seqNum, imap.UID(sourceUID), imapFlags, m.sessionTracker)
1181	}
1182	logger.Info("end STORE")
1183	return nil
1184}
1185
1186func (m *imapSession) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) {
1187	logger := slog.With("session_id", m.sessionID, "cmd", "COPY", "user_id", m.user.ID)
1188	logger.Info("start COPY", "num_set", numSet, "dest", dest)
1189	if m.selectedMailbox.ID == -1 {
1190		logger.Info("no mailbox selected")
1191		return nil, errNoMailboxSelected
1192	}
1193
1194	// must find destination mailbox, otherwise return failure (see RFC)
1195	mailboxID, uidValidity, err := m.db.GetMailboxID(context.Background(), m.user.ID, dest)
1196	if err != nil {
1197		if errors.Is(err, db.ErrMailboxNotFound) {
1198			logger.Info("destination mailbox not found")
1199			return nil, errMailboxNotExist
1200		}
1201		logger.Error("unable to get mailbox ID", "error", err)
1202		return nil, errInternalServerError
1203	}
1204
1205	sourceUIDs, err := m.resolveUIDs(context.Background(), numSet)
1206	if err != nil {
1207		if errors.Is(err, errDBSequenceOutOfRange) {
1208			logger.Info("sequence out of range")
1209			return nil, errSequenceOutOfRange
1210		}
1211		logger.Error("unable to resolve UIDs", "error", err)
1212		return nil, errInternalServerError
1213	}
1214
1215	// Filter non-existing UIDs to prevent copyMessagesToMailbox from burning
1216	// destination UIDs for messages that don't exist in the source mailbox.
1217	filteredSourceUIDs := make([]uint32, 0, len(sourceUIDs))
1218	for _, uid := range sourceUIDs {
1219		exists, err := m.db.UIDExists(context.Background(), m.user.ID, uid)
1220		if err != nil {
1221			logger.Error("unable to retrieve UID", "error", err)
1222			return nil, errInternalServerError
1223		}
1224		if exists {
1225			filteredSourceUIDs = append(filteredSourceUIDs, uid)
1226		}
1227	}
1228
1229	destUIDs, err := m.db.CopyMessagesToMailbox(context.Background(), m.selectedMailbox.ID, mailboxID, filteredSourceUIDs)
1230	if err != nil {
1231		logger.Error("unable to copy messages to mailbox", "error", err)
1232		return nil, errInternalServerError
1233	}
1234
1235	logger.Info("end COPY")
1236	return &imap.CopyData{
1237		UIDValidity: uidValidity,
1238		SourceUIDs:  internalIDToUIDSet(filteredSourceUIDs),
1239		DestUIDs:    internalIDToUIDSet(destUIDs),
1240	}, nil
1241}
1242
1243func (m *imapSession) Namespace() (*imap.NamespaceData, error) {
1244	logger := slog.With("session_id", m.sessionID, "cmd", "NAMESPACE", "user_id", m.user.ID)
1245	logger.Info("start NAMESPACE")
1246	logger.Info("end NAMESPACE")
1247	return &imap.NamespaceData{
1248		Personal: []imap.NamespaceDescriptor{
1249			{
1250				Prefix: "",
1251				Delim:  '/',
1252			},
1253		},
1254		Other:  nil,
1255		Shared: nil,
1256	}, nil
1257}
1258
1259// Move SELECTED state
1260func (m *imapSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest string) error {
1261	ctx := context.Background()
1262
1263	logger := slog.With("session_id", m.sessionID, "cmd", "MOVE", "user_id", m.user.ID)
1264	logger.InfoContext(ctx, "start MOVE", "num_set", numSet, "dest", dest)
1265
1266	if m.selectedMailbox.ID == -1 {
1267		logger.InfoContext(ctx, "no mailbox selected")
1268		return errNoMailboxSelected
1269	}
1270
1271	if m.selectedMailboxReadOnly {
1272		logger.InfoContext(ctx, "mailbox selected as read-only")
1273		return errMailboxReadOnly
1274	}
1275
1276	// must find destination mailbox, otherwise return failure (see RFC)
1277	destMailboxID, uidValidity, err := m.db.GetMailboxID(ctx, m.user.ID, dest)
1278	if err != nil {
1279		if errors.Is(err, db.ErrMailboxNotFound) {
1280			logger.InfoContext(ctx, "destination mailbox not found")
1281			return errMailboxNotExist
1282		}
1283		logger.ErrorContext(ctx, "unable to get mailbox ID", "error", err)
1284		return errInternalServerError
1285	}
1286
1287	if m.selectedMailbox.ID == destMailboxID {
1288		logger.InfoContext(ctx, "moving messages within the same mailbox is not supported")
1289		return &imap.Error{
1290			Type: imap.StatusResponseTypeNo,
1291			Code: imap.ResponseCodeCannot,
1292			Text: "messages already moved",
1293		}
1294	}
1295
1296	sourceUIDs, err := m.resolveUIDs(ctx, numSet)
1297	if err != nil {
1298		if errors.Is(err, errDBSequenceOutOfRange) {
1299			logger.InfoContext(ctx, "sequence out of range")
1300			return errSequenceOutOfRange
1301		}
1302		logger.ErrorContext(ctx, "unable to resolve UIDs", "error", err)
1303		return errInternalServerError
1304	}
1305
1306	// Filter non-existing UIDs to prevent burning destination UIDs
1307	filteredSourceUIDs := make([]uint32, 0, len(sourceUIDs))
1308	for _, uid := range sourceUIDs {
1309		exists, err := m.db.UIDExists(ctx, m.user.ID, uid)
1310		if err != nil {
1311			logger.ErrorContext(ctx, "unable to retrieve UID", "error", err)
1312			return errInternalServerError
1313		}
1314		if exists {
1315			filteredSourceUIDs = append(filteredSourceUIDs, uid)
1316		}
1317	}
1318
1319	if len(filteredSourceUIDs) == 0 {
1320		logger.InfoContext(ctx, "no messages to move")
1321		return nil
1322	}
1323
1324	// for expunging later, we need to calculate the serverSeq now
1325	expungeServerSeq := make([]uint32, len(filteredSourceUIDs))
1326	for i, uid := range filteredSourceUIDs {
1327		serverSeq, err := m.db.UIDToServerSeq(ctx, m.selectedMailbox.ID, uid)
1328		if err != nil {
1329			logger.ErrorContext(ctx, "unable to convert to server seq", "error", err)
1330			return errInternalServerError
1331		}
1332		expungeServerSeq[i] = serverSeq
1333	}
1334
1335	// Fetch message envelopes BEFORE the move (after the move, dest UIDs are
1336	// reassigned and source UIDs no longer resolve to live rows).
1337	movedMessages, err := m.db.GetMailboxMessagesByUID(ctx, m.user.ID, m.selectedMailbox.ID, filteredSourceUIDs)
1338	if err != nil {
1339		logger.ErrorContext(ctx, "unable to fetch messages for policy hook", "error", err)
1340		return errInternalServerError
1341	}
1342
1343	destUIDs, err := m.db.MoveMessagesToMailbox(ctx, m.selectedMailbox.ID, destMailboxID, filteredSourceUIDs)
1344	if err != nil {
1345		logger.ErrorContext(ctx, "unable to move messages to mailbox", "error", err)
1346		return errInternalServerError
1347	}
1348
1349	// Fire the on_message_moved policy hook for each moved message. The move
1350	// has already succeeded, so policy errors are logged but do not fail the
1351	// MOVE command.
1352	policyUser := policy.UserToStarlark(m.user, m.db)
1353	for _, mv := range movedMessages {
1354		msg := policy.MessageContextFromEnvelope(mv.EnvelopeFrom.String, mv.EnvelopeSubject.String, mv.RFC822Size)
1355		if err := m.policyEngine.OnMessageMoved(policyUser, msg, m.selectedMailbox.Name, dest); err != nil {
1356			logger.ErrorContext(ctx, "on_message_moved policy hook error", "error", err, "uid", mv.UID)
1357		}
1358	}
1359
1360	// RFC 9051: Servers are also REQUIRED to send the COPYUID response code in an untagged OK before sending
1361	// EXPUNGE or similar responses.
1362	err = w.WriteCopyData(&imap.CopyData{
1363		UIDValidity: uidValidity,
1364		SourceUIDs:  internalIDToUIDSet(filteredSourceUIDs),
1365		DestUIDs:    internalIDToUIDSet(destUIDs),
1366	})
1367	if err != nil {
1368		logger.ErrorContext(ctx, "unable to write copy data", "error", err)
1369		return errInternalServerError
1370	}
1371
1372	slices.Reverse(expungeServerSeq)
1373	for _, serverSeq := range expungeServerSeq {
1374		clientSeq := m.sessionTracker.EncodeSeqNum(serverSeq)
1375		if clientSeq == 0 {
1376			continue
1377		}
1378		m.mTracker.get(m.user.ID, m.selectedMailbox.ID).QueueExpunge(clientSeq)
1379	}
1380
1381	destMsgCount, err := m.db.MailboxMessageCount(ctx, destMailboxID)
1382	if err != nil {
1383		logger.ErrorContext(ctx, "unable to get mailbox message count", "error", err)
1384		return errInternalServerError
1385	}
1386	destMailboxTracker := m.mTracker.getMailboxTracker(m.user.ID, destMailboxID, destMsgCount)
1387	destMailboxTracker.QueueNumMessages(destMsgCount)
1388
1389	logger.Info("end MOVE")
1390	return nil
1391}
1392
1393func internalIDToUIDSet(internalID []uint32) imap.UIDSet {
1394	out := imap.UIDSet{}
1395	for _, i := range internalID {
1396		out.AddNum(imap.UID(i))
1397	}
1398	if len(out) == 0 {
1399		return nil // empty UIDSets are not allowed
1400	}
1401	return out
1402}
1403
1404func resolveRange[T ~uint32](start, stop, last T) (T, T) {
1405	if start == 0 {
1406		start = last
1407	}
1408	if stop == 0 {
1409		stop = last
1410	}
1411	return start, stop
1412}
1413
1414// resolveUIDs resolves a given imap.NumSet to the internal representation of imap UIDs which is uint32
1415func (m *imapSession) resolveUIDs(ctx context.Context, numSet imap.NumSet) ([]uint32, error) {
1416	staticSet, err := m.resolveDynamicSet(ctx, numSet)
1417	if err != nil {
1418		if errors.Is(err, sql.ErrNoRows) {
1419			return nil, nil
1420		}
1421		return nil, err
1422	}
1423
1424	switch v := staticSet.(type) {
1425	case imap.UIDSet:
1426		nums, ok := v.Nums()
1427		if !ok {
1428			// This should theoretically not happen after dynamic resolution,
1429			// but we handle it defensively.
1430			return nil, fmt.Errorf("unresolvable UIDSet")
1431		}
1432		out := make([]uint32, len(nums))
1433		for i, num := range nums {
1434			out[i] = uint32(num)
1435		}
1436		return out, nil
1437
1438	case imap.SeqSet:
1439		nums, ok := v.Nums()
1440		if !ok {
1441			return nil, fmt.Errorf("unresolvable SeqSet")
1442		}
1443
1444		uids := make([]uint32, 0, len(nums))
1445		for _, num := range nums {
1446			uid, err := m.db.ServerSeqToUID(ctx, m.selectedMailbox.ID, m.sessionTracker.DecodeSeqNum(num))
1447			if err != nil {
1448				if errors.Is(err, sql.ErrNoRows) {
1449					return nil, errDBSequenceOutOfRange
1450				}
1451				return nil, err
1452			}
1453			uids = append(uids, uid)
1454		}
1455		return uids, nil
1456	}
1457
1458	return nil, fmt.Errorf("unsupported NumSet type %T", numSet)
1459}
1460
1461// resolveDynamicSet resolves the NumSet to the client view, so it can be converted later to the server view
1462func (m *imapSession) resolveDynamicSet(ctx context.Context, numSet imap.NumSet) (imap.NumSet, error) {
1463	if !numSet.Dynamic() {
1464		return numSet, nil
1465	}
1466
1467	if numSet.String() == "$" {
1468		return m.searchRes, nil
1469	}
1470
1471	switch v := numSet.(type) {
1472	case imap.UIDSet:
1473		last, err := m.db.GetLastUID(ctx, m.user.ID, m.selectedMailbox.ID)
1474		if err != nil {
1475			return nil, err
1476		}
1477		if last == 0 {
1478			return imap.UIDSet{}, nil // empty mailbox: UID commands match nothing
1479		}
1480		out := imap.UIDSet{}
1481		for _, r := range v {
1482			start, stop := resolveRange(r.Start, r.Stop, last)
1483			out.AddRange(start, stop)
1484		}
1485		return out, nil
1486
1487	case imap.SeqSet:
1488		last, err := m.db.MailboxMessageCount(ctx, m.selectedMailbox.ID)
1489		if err != nil {
1490			return nil, err
1491		}
1492		if last == 0 {
1493			return imap.SeqSet{}, nil // empty mailbox: "*" is 0, so any seq set matches nothing
1494		}
1495		out := imap.SeqSet{}
1496		for _, r := range v {
1497			start, stop := resolveRange(r.Start, r.Stop, m.sessionTracker.EncodeSeqNum(last))
1498			out.AddRange(start, stop)
1499		}
1500		return out, nil
1501
1502	default:
1503		return numSet, nil
1504	}
1505}