all repos — postern

@ 70b0cd719774b75e29d0d40afa1435789c220c97

Modern mail management

clone
git clone https:///postern.git

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