package imap import ( "context" "database/sql" "errors" "fmt" "log/slog" "math" "slices" "strings" "time" "postern/internal/db" "postern/internal/model" "postern/internal/persistence" "postern/internal/policy" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapserver" "github.com/google/uuid" ) type imapSession struct { db *db.DB conn *imapserver.Conn mTracker *mailboxTrackerRegistry persistence *persistence.Persistence searchRes imap.NumSet // RFC 9051 6.4.4.1 sessionTracker *imapserver.SessionTracker selectedMailbox model.Mailbox selectedMailboxReadOnly bool user model.User sessionID string policyEngine *policy.Engine } func (i *Server) handleIMAPConn(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { sessionID := uuid.NewString() slog.Info("handle new IMAP connection", "remote_ip", conn.NetConn().RemoteAddr().String(), "session_id", sessionID) return &imapSession{ db: i.db, conn: conn, selectedMailbox: model.Mailbox{}, mTracker: i.mTracker, persistence: i.persistence, sessionTracker: nil, sessionID: sessionID, policyEngine: i.policyEngine, }, &imapserver.GreetingData{}, nil } func (m *imapSession) Close() error { slog.Info("close IMAP session", "session_id", m.sessionID) // Package calls EXPUNGE internally, which leads to a bug in which // CLOSE emits an error instead of silently closing for read-only SELECTs if m.sessionTracker != nil { m.sessionTracker.Close() } m.selectedMailbox = model.Mailbox{} return nil } func (m *imapSession) Login(username, password string) error { logger := slog.With("session_id", m.sessionID, "cmd", "LOGIN", "username", username) u, err := m.db.GetUser(context.Background(), username) if err != nil { if errors.Is(err, sql.ErrNoRows) { logger.Info("user not found") } else { logger.Error("getting user", "error", err) } return imapserver.ErrAuthFailed } err = u.VerifyPassword([]byte(password)) if err != nil { slog.Info("invalid password", "username", username) return imapserver.ErrAuthFailed } logger.Info("logged in", "user_id", u.ID) m.user = u return nil } // Select and Examine are identical besides read-only (https://www.ietf.org/rfc/rfc9051.html#name-examine-command) func (m *imapSession) Select(mailboxName string, selectOpt *imap.SelectOptions) (*imap.SelectData, error) { logger := slog.With("session_id", m.sessionID, "cmd", "SELECT", "user_id", m.user.ID) // INBOX is case-insensitive if strings.EqualFold(mailboxName, string(model.MailboxINBOX)) { mailboxName = string(model.MailboxINBOX) } logger.Info("start SELECT", "mailbox", mailboxName, "read_only", selectOpt.ReadOnly) userMailbox, err := m.db.GetUserMailbox(context.Background(), m.user.ID, mailboxName) if err != nil { if errors.Is(err, db.ErrMailboxNotFound) { return nil, errMailboxNotExist } logger.Error("getting mailbox", "error", err) return nil, err } if m.selectedMailbox.ID != userMailbox.ID && m.sessionTracker != nil { m.sessionTracker.Close() m.sessionTracker = nil } m.sessionTracker = m.mTracker.getMailboxTracker(m.user.ID, userMailbox.ID, userMailbox.NumMessages).NewSession() m.selectedMailbox = userMailbox m.selectedMailboxReadOnly = selectOpt.ReadOnly activeFlags, err := m.db.GetMailboxFlags(context.Background(), m.selectedMailbox.ID) if err != nil { logger.Error("getting mailbox flags", "error", err) return nil, err } // RFC 9051: Upon successful completion of a SELECT or an EXAMINE command (after the tagged OK response), // the current search result variable is reset to the empty sequence. m.searchRes = imap.UIDSet{} logger.Info("end SELECT") return &imap.SelectData{ NumMessages: userMailbox.NumMessages, NumRecent: 0, // (legacy, send 0) UIDValidity: userMailbox.UIDValidity, UIDNext: imap.UID(userMailbox.UIDNext), PermanentFlags: []imap.Flag{ imap.FlagWildcard, imap.FlagFlagged, imap.FlagSeen, imap.FlagDeleted, imap.FlagAnswered, imap.FlagDraft, }, Flags: toIMAPFlags(activeFlags), List: &imap.ListData{ Attrs: getMailboxAttr(userMailbox), Delim: '/', Mailbox: mailboxName, Status: &imap.StatusData{ Mailbox: mailboxName, NumMessages: new(userMailbox.NumMessages), NumRecent: new(uint32), // obsolete UIDNext: imap.UID(userMailbox.UIDNext), UIDValidity: userMailbox.UIDValidity, NumUnseen: new(uint32(userMailbox.NumUnseen)), NumDeleted: new(uint32(userMailbox.NumDeleted)), Size: new(userMailbox.Size), AppendLimit: new(uint32), DeletedStorage: new(int64), HighestModSeq: uint64(userMailbox.HighestModSeq), }, }, }, nil } func (m *imapSession) Create(mailbox string, _ *imap.CreateOptions) error { logger := slog.With("session_id", m.sessionID, "cmd", "CREATE", "user_id", m.user.ID) logger.Info("start CREATE", "mailbox", mailbox) if strings.EqualFold(mailbox, string(model.MailboxINBOX)) { logger.Info("creating INBOX not allowed") return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeAlreadyExists, Text: "Mailbox INBOX always exists", } } // per RFC 9051, 7.3.1, all mailbox attributes are optional. // Furthermore, they are user-specific in case of a shared mailbox (for example \Sent has per-user meaning). // Ignore special attributes now because we have pre-defined mailboxes already, which should be un-deletable. // mailbox should exist under the name without the trailing delimiter mailbox, _ = strings.CutPrefix(mailbox, "/") mailbox, _ = strings.CutSuffix(mailbox, "/") // This can be a toctou, but we have a unique constraint on mailbox names mbID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox) if err != nil && !errors.Is(err, db.ErrMailboxNotFound) { logger.Error("getting mailbox", "error", err) return errInternalServerError } if mbID != 0 { logger.Info("cannot create already existing mailbox", "mailbox", mailbox) return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "Mailbox already exists", } } err = m.db.CreateMailbox(context.Background(), m.user.ID, mailbox) if err != nil { logger.Error("creating mailbox", "error", err) return errInternalServerError } logger.Info("end CREATE") return nil } func (m *imapSession) Delete(mailbox string) error { logger := slog.With("session_id", m.sessionID, "cmd", "DELETE", "user_id", m.user.ID) logger.Info("start DELETE", "mailbox", mailbox) // We do not allow to delete default mailboxes because we need their special use present for _, dmb := range model.DefaultMailboxes() { if string(dmb.Name) == mailbox { logger.Info("denied deleting default mailbox", "mailbox", mailbox) return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeCannot, Text: "Deleting preset mailbox is not allowed", } } } mailboxID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox) if errors.Is(err, db.ErrMailboxNotFound) { return errMailboxNotExist } if err != nil { logger.Error("getting mailbox ID", "error", err) return errInternalServerError } // deleting messages is left to the "prune" CLI and not part of this operation err = m.db.DeleteMailbox(context.Background(), m.user.ID, mailbox) if err != nil { logger.Error("deleting mailbox", "error", err) return errInternalServerError } m.mTracker.remove(m.user.ID, mailboxID) logger.Info("end DELETE", "mailbox", mailbox) return nil } func (m *imapSession) Rename(mailbox, newName string, _ *imap.RenameOptions) error { logger := slog.With("session_id", m.sessionID, "cmd", "RENAME", "user_id", m.user.ID) logger.Info("start RENAME", "mailbox", mailbox, "new_mailbox_name", newName) if mailbox == string(model.MailboxINBOX) { // Special case per RFC 6.3.6: // Renaming INBOX is permitted and does not result in a tagged BAD response, and it has special behavior: // It moves all messages in INBOX to a new mailbox with the given name, leaving INBOX empty. If the server // implementation supports inferior hierarchical names of INBOX, these are unaffected by a rename of INBOX. err := m.db.CreateAndMoveAllMessagesFromINBOXToMailbox(context.Background(), m.user.ID, newName) if err != nil { logger.Error("rename INBOX", "error", err) return errInternalServerError } logger.Info("end RENAME", "mailbox", mailbox, "new_mailbox_name", newName) return nil } // We do not allow to rename default mailboxes because we need their special use present for _, dmb := range model.DefaultMailboxes() { if string(dmb.Name) == mailbox { logger.Info("denied renaming default mailbox", "mailbox", mailbox) return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeCannot, Text: "Renaming preset mailbox is not allowed", } } } // Check if mailbox exists _, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox) if errors.Is(err, db.ErrMailboxNotFound) { logger.Info("mailbox does not exist", "mailbox", mailbox) return errMailboxNotExist } if err != nil { logger.Error("getting mailbox ID", "error", err) return errInternalServerError } // Check if new mailbox name already exists newMailboxID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, newName) if newMailboxID != 0 { slog.Info("new mailbox name already exists", "new_mailbox_name", newMailboxID) return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeAlreadyExists, Text: "Mailbox with the same name already exists", } } if err != nil && !errors.Is(err, db.ErrMailboxNotFound) { logger.Error("getting mailbox ID", "error", err) return errInternalServerError } err = m.db.RenameMailbox(context.Background(), m.user.ID, mailbox, newName) if err != nil { logger.Error("renaming mailbox", "error", err) return errInternalServerError } logger.Info("end RENAME", "mailbox", mailbox, "new_mailbox_name", newName) return nil } func (m *imapSession) Subscribe(mb string) error { logger := slog.With("session_id", m.sessionID, "cmd", "SUBSCRIBE", "user_id", m.user.ID) logger.Info("start SUBSCRIBE", "mailbox", mb) mailboxID, _, err := m.db.GetMailboxID(context.Background(), m.user.ID, mb) if err != nil { if errors.Is(err, db.ErrMailboxNotFound) { return errMailboxNotExist } logger.Error("getting mailbox ID", "error", err) return errInternalServerError } err = m.db.Subscribe(context.Background(), m.user.ID, mailboxID, mb) if err != nil { logger.Error("subscribe mailbox", "error", err) return errInternalServerError } logger.Info("end SUBSCRIBE", "mailbox", mb) return nil } func (m *imapSession) Unsubscribe(mb string) error { logger := slog.With("session_id", m.sessionID, "cmd", "UNSUBSCRIBE", "user_id", m.user.ID) logger.Info("start UNSUBSCRIBE", "mailbox", mb) // We do not allow to unsubscribe from default mailboxes because we need their special use present for _, dmb := range model.DefaultMailboxes() { if string(dmb.Name) == mb { logger.Info("denied unsubscribing default mailbox", "mailbox", mb) return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeCannot, Text: "Unsubscribing preset mailbox is not allowed", } } } err := m.db.Unsubscribe(context.Background(), m.user.ID, mb) if err != nil { logger.Error("unsubscribe mailbox", "error", err) return errInternalServerError } logger.Info("end UNSUBSCRIBE", "mailbox", mb) return nil } func (m *imapSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { logger := slog.With("session_id", m.sessionID, "cmd", "LIST", "user_id", m.user.ID) logger.Info("start LIST", "ref", ref, "patterns", patterns, "options", options) // Special case LIST "" "" - mainly for the client to get the folder hierarchy delimiter if ref == "" && len(patterns) == 0 { err := w.WriteList(&imap.ListData{ Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect}, Delim: '/', Mailbox: "", }) if err != nil { logger.Error("listing mailboxes", "error", err) return errInternalServerError } logger.Info("end LIST \"\" \"\"") return nil } // LIST (SUBSCRIBED) if options.SelectSubscribed { subscribed, err := m.db.ListSubscribed(context.Background(), m.user.ID) if err != nil { logger.Error("listSubscribed", "error", err) return errInternalServerError } for mb, exists := range subscribed { for _, pattern := range patterns { // We MUST pattern match subscribed mailboxes if imapserver.MatchList(mb.Name, '/', ref, pattern) { attr := getMailboxAttr(mb) if !exists { attr = append(attr, imap.MailboxAttrNonExistent, imap.MailboxAttrNoSelect) } err = w.WriteList(&imap.ListData{ Attrs: attr, Delim: '/', Mailbox: mb.Name, }) if err != nil { logger.Error("listing mailboxes", "error", err) return errInternalServerError } // Matched this mailbox, don't write it again if it matches a 2nd pattern break } } } logger.Info("end LIST subscribed") return nil } // Standard LIST "" "*" mailboxes, err := m.db.GetUserMailboxes(context.Background(), m.user.ID) if err != nil { logger.Error("get user mailboxes", "error", err) return errInternalServerError } hasChildren := calcHasChildren(mailboxes) for _, mb := range mailboxes { for _, pattern := range patterns { match := imapserver.MatchList(mb.Name, '/', ref, pattern) logger.Debug("LIST imapserver.MatchList", "mailbox", mb.Name, "ref", ref, "pattern", pattern, "match", match) if match { attr := getMailboxAttr(mb) // RFC 9051: MAY return children attribute even if it was not requested if hasChildren[mb.Name] { attr = append(attr, imap.MailboxAttrHasChildren) } else { attr = append(attr, imap.MailboxAttrHasNoChildren) } // 2. Pass the addresses (&) of those local variables numMessages := mb.NumMessages numUnseen := uint32(mb.NumUnseen) numDeleted := uint32(mb.NumDeleted) size := mb.Size err := w.WriteList(&imap.ListData{ Attrs: attr, Delim: '/', Mailbox: mb.Name, ChildInfo: nil, // Should be returned on RECURSIVEMATCH Status: &imap.StatusData{ Mailbox: mb.Name, UIDNext: imap.UID(mb.UIDNext), UIDValidity: mb.UIDValidity, HighestModSeq: uint64(mb.HighestModSeq), NumMessages: &numMessages, NumUnseen: &numUnseen, NumDeleted: &numDeleted, Size: &size, NumRecent: new(uint32), AppendLimit: new(uint32), DeletedStorage: new(int64), }, }) if err != nil { logger.Error("listing mailboxes", "error", err) return errInternalServerError } logger.Debug("WriteList", "mailbox", mb.Name, "attr", attr) // Matched this mailbox, don't write it again break } } } logger.Info("end LIST") return nil } func calcHasChildren(mailboxes []model.Mailbox) map[string]bool { hasChildren := make(map[string]bool) for _, mb := range mailboxes { s := strings.Split(mb.Name, "/") if len(s) > 1 { hasChildren[strings.Join(s[:len(s)-1], "/")] = true } } return hasChildren } func (m *imapSession) Status(mailboxName string, _ *imap.StatusOptions) (*imap.StatusData, error) { logger := slog.With("session_id", m.sessionID, "cmd", "STATUS", "user_id", m.user.ID) logger.Info("start STATUS", "mailbox_name", mailboxName) userMailboxes, err := m.db.GetUserMailboxes(context.Background(), m.user.ID) if err != nil { logger.Error("get user mailboxes", "error", err) return &imap.StatusData{}, errInternalServerError } var statusBox model.Mailbox found := false for _, mb := range userMailboxes { if mb.Name == mailboxName { statusBox = mb found = true break } } if !found { logger.Info("mailbox not found", "name", mailboxName) return &imap.StatusData{}, errMailboxNotExist } logger.Info("end STATUS", "mailbox_name", mailboxName) return &imap.StatusData{ Mailbox: mailboxName, NumMessages: new(statusBox.NumMessages), NumRecent: new(uint32), // obsolete UIDNext: imap.UID(statusBox.UIDNext), UIDValidity: statusBox.UIDValidity, NumUnseen: new(uint32(statusBox.NumUnseen)), NumDeleted: new(uint32(statusBox.NumDeleted)), Size: new(statusBox.Size), AppendLimit: new(uint32), DeletedStorage: new(int64), HighestModSeq: uint64(statusBox.HighestModSeq), }, nil } func (m *imapSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { logger := slog.With("session_id", m.sessionID, "cmd", "APPEND", "user_id", m.user.ID) logger.Info("start APPEND", "mailbox", mailbox) // If the destination mailbox does not exist, a server MUST return an error and MUST NOT automatically create the // mailbox. Unless it is certain that the destination mailbox cannot be created, the server MUST send the response // code "[TRYCREATE]" as the prefix of the text of the tagged NO response. This gives a hint to the client that it // can attempt a CREATE command and retry the APPEND if the CREATE is successful. mailboxID, uidValidity, err := m.db.GetMailboxID(context.Background(), m.user.ID, mailbox) if err != nil { if errors.Is(err, db.ErrMailboxNotFound) { logger.Info("mailbox not found", "mailbox", mailbox) return nil, errMailboxNotExist } logger.Error("get mailbox", "error", err) return nil, errInternalServerError } for _, f := range options.Flags { if !isAllowedFlag(f) { logger.Info("flag not allowed", "flag", f) return nil, errBadFlag } } internalTime := time.Now() if !options.Time.IsZero() { internalTime = options.Time } fileName, size, parsedMsg, err := m.persistence.WriteBlobMessage(r) if err != nil { logger.Error("write blob", "error", err) return nil, errInternalServerError } logger.Info("wrote file", "filename", fileName, "size", size) uid, err := m.db.AppendMessage(context.Background(), mailboxID, m.user.ID, fileName, size, internalTime.UTC().Format(time.RFC3339), fromIMAPFlags(options.Flags), parsedMsg) if err != nil { logger.Error("write message metadata", "error", err) return nil, errInternalServerError } // In the case of a mailbox that has permissions set so that the client can APPEND to the mailbox, but not SELECT or // EXAMINE it, the server MUST NOT send an APPENDUID response code as it would disclose information about the mailbox. mb, err := m.db.GetUserMailbox(context.Background(), m.user.ID, mailbox) if err != nil { logger.Error("get user mailbox", "error", err) return nil, errInternalServerError } t := m.mTracker.get(m.user.ID, mailboxID) if t != nil { t.QueueNumMessages(mb.NumMessages) } logger.Info("end APPEND", "mailbox", mailbox) return &imap.AppendData{ UID: imap.UID(uid), UIDValidity: uidValidity, }, nil } func (m *imapSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error { logger := slog.With("session_id", m.sessionID, "cmd", "POLL", "user_id", m.user.ID) logger.Debug("start POLL") if m.sessionTracker == nil { logger.Debug("session tracker not initialized") return nil } err := m.sessionTracker.Poll(w, allowExpunge) if err != nil { logger.Error("poll session tracker", "error", err) return errInternalServerError } logger.Debug("end POLL") return nil } func (m *imapSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error { logger := slog.With("session_id", m.sessionID, "cmd", "IDLE", "user_id", m.user.ID) logger.Debug("start IDLE") if m.sessionTracker == nil { logger.Debug("session tracker not initialized") return nil } err := m.sessionTracker.Idle(w, stop) if err != nil { logger.Error("idle session tracker", "error", err) return errInternalServerError } logger.Debug("end IDLE") return nil } func (m *imapSession) Unselect() error { logger := slog.With("session_id", m.sessionID, "cmd", "UNSELECT", "user_id", m.user.ID) logger.Info("start UNSELECT") m.selectedMailbox = model.Mailbox{} m.sessionTracker.Close() m.sessionTracker = nil logger.Info("end UNSELECT") return nil } // removeFromSearchResult returns res with the given uid/seq removed. // If res is not a set type we know about, it is returned unchanged. func removeFromSearchResult(res imap.NumSet, uid, seq uint32) imap.NumSet { switch s := res.(type) { case imap.UIDSet: if !s.Contains(imap.UID(uid)) { return s } out := imap.UIDSetNum() nums, _ := s.Nums() for _, n := range nums { if n != imap.UID(uid) { out.AddNum(n) // FIX: was adding the deleted uid } } return out case imap.SeqSet: if !s.Contains(seq) { return s } out := imap.SeqSetNum() nums, _ := s.Nums() for _, n := range nums { if n != seq { out.AddNum(n) // FIX: was adding the deleted seq } } return out } return res } // Expunge SELECTED state func (m *imapSession) Expunge(_ *imapserver.ExpungeWriter, uids *imap.UIDSet) error { ctx := context.Background() logger := slog.With("session_id", m.sessionID, "cmd", "EXPUNGE", "user_id", m.user.ID) logger.InfoContext(ctx, "start EXPUNGE", "uids", uids) if m.selectedMailboxReadOnly { logger.InfoContext(ctx, "selected mailbox is read-only") // This will either break Close() or Expunge() because CLOSE expects a clean OK, EXPUNGE below error return errMailboxReadOnly } if m.selectedMailbox.ID == -1 { logger.InfoContext(ctx, "no mailbox selected") return errNoMailboxSelected } // UID EXPUNGE restricts removal to the given set. A nil or empty set means // "expunge every \Deleted message" (plain EXPUNGE semantics). uidFilter := func(uid imap.UID) bool { return true } if uids != nil { if nums, _ := uids.Nums(); len(nums) > 0 { uidFilter = func(uid imap.UID) bool { return uids.Contains(uid) } } } toBeDeleted, err := m.db.GetAllFlaggedDeletedMessages(ctx, m.selectedMailbox.ID) if err != nil { logger.ErrorContext(ctx, "get flagged deleted messages", "error", err) return errInternalServerError } logger.InfoContext(ctx, "candidates", "mailbox_id", m.selectedMailbox.ID, "uids", toBeDeleted) // Resolve all seqnums BEFORE any deletion, then delete high-to-low. // RFC 9051: The UID EXPUNGE command permanently removes all messages that have both the \Deleted flag set and a UID // that is included in the specified sequence set from the currently selected mailbox. If a message either does not // have the \Deleted flag set or has a UID that is not included in the specified sequence set, it is not affected. type expunge struct{ uid, seq uint32 } list := make([]expunge, 0, len(toBeDeleted)) for _, uid := range toBeDeleted { if !uidFilter(imap.UID(uid)) { continue } seq, err := m.db.UIDToServerSeq(ctx, m.selectedMailbox.ID, uid) if err != nil { logger.ErrorContext(ctx, "UID to server seq", "error", err) return errInternalServerError } list = append(list, expunge{uid, seq}) } slices.SortFunc(list, func(i, j expunge) int { return int(j.seq) - int(i.seq) }) // descending tracker := m.mTracker.get(m.user.ID, m.selectedMailbox.ID) for _, seqNumUID := range list { err = m.db.DeleteMessageFromMailbox(ctx, m.selectedMailbox.ID, seqNumUID.uid) if err != nil { logger.ErrorContext(ctx, "delete message from mailbox", "error", err) return errInternalServerError } if tracker != nil { tracker.QueueExpunge(seqNumUID.seq) } m.searchRes = removeFromSearchResult(m.searchRes, seqNumUID.uid, seqNumUID.seq) } logger.InfoContext(ctx, "end EXPUNGE") return nil } // Search SELECTED State func (m *imapSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { logger := slog.With("session_id", m.sessionID, "cmd", "SEARCH", "user_id", m.user.ID) logger.Info("start SEARCH", "criteria", criteria, "options", options) allMailboxMessages, err := m.db.GetAllMailboxMessages(context.Background(), m.user.ID, m.selectedMailbox.ID) if err != nil { logger.Error("Unable to retrieve messages for searching", "error", err) return nil, errInternalServerError } uidSet := make([]imap.UID, 0) uidSetCount := 0 // leads to wrong results with len(uidSet) for _, msg := range allMailboxMessages { if m.evalSearchCriteria(criteria, msg, m.sessionTracker, m.persistence) { uidSet = append(uidSet, imap.UID(msg.UID)) uidSetCount++ } } slices.Sort(uidSet) uidMin := 0 if len(uidSet) > 0 { uidMin = int(uidSet[0]) } uidMax := 0 if len(uidSet) > 0 { uidMax = int(uidSet[len(uidSet)-1]) } uidSetNum := imap.UIDSetNum(uidSet...) // Combination of Result Option "$" Marker Value //SAVE MIN -> MIN //SAVE MAX -> MAX //SAVE MIN MAX -> MIN & MAX //SAVE * [m] -> all found messages if options.ReturnSave { m.searchRes = uidSetNum if options.ReturnMin { m.searchRes = imap.UIDSetNum(imap.UID(uint32(uidMin))) } if options.ReturnMax { m.searchRes = imap.UIDSetNum(imap.UID(uint32(uidMax))) } if options.ReturnMin && options.ReturnMax { n := imap.UIDSetNum(imap.UID(uint32(uidMin))) n.AddNum(imap.UID(uint32(uidMax))) m.searchRes = n } } if kind == imapserver.NumKindUID { res := &imap.SearchData{ All: uidSetNum, Count: uint32(uidSetCount), Min: uint32(uidMin), Max: uint32(uidMax), } logger.Info("end SEARCH", "search_result", res) return res, nil } if kind == imapserver.NumKindSeq { out := imap.SeqSetNum() seqNumMin := uint32(math.MaxUint32) seqNumMax := uint32(0) seqNumCount := 0 num, _ := uidSetNum.Nums() for _, n := range num { serverSeq, err := m.db.UIDToServerSeq(context.Background(), m.selectedMailbox.ID, uint32(n)) if err != nil { logger.Error("Unable to retrieve server seq", "error", err) return nil, errInternalServerError } seqNumCount++ out.AddNum(serverSeq) if serverSeq < seqNumMin { seqNumMin = serverSeq } if serverSeq > seqNumMax { seqNumMax = serverSeq } } res := &imap.SearchData{ All: out, Count: uint32(seqNumCount), Min: seqNumMin, Max: seqNumMax, } logger.Info("end SEARCH", "search_result", res) return res, nil } return nil, errInternalServerError } // Fetch SELECTED State func (m *imapSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { logger := slog.With("session_id", m.sessionID, "cmd", "FETCH", "user_id", m.user.ID) logger.Info("start FETCH", "num_set", numSet, "options", options) var outMessages []model.Message var err error sourceUIDs, err := m.resolveUIDs(context.Background(), numSet) if err != nil { if errors.Is(err, errDBSequenceOutOfRange) { logger.Info("sequence out of range") return errSequenceOutOfRange } logger.Error("unable to resolve UIDs", "error", err) return errInternalServerError } // getMailboxMessagesByUID inherently filters out non-existing UIDs // because it queries by mailboxID and the resolved sourceUIDs. outMessages, err = m.db.GetMailboxMessagesByUID(context.Background(), m.user.ID, m.selectedMailbox.ID, sourceUIDs) if err != nil { logger.Error("unable to fetch mailbox messages", "error", err) return errInternalServerError } // TODO but more on a global scale than just here: // seq-number = nz-number / "*" // ; message sequence number (COPY, FETCH, STORE // ; commands) or unique identifier (UID COPY, // ; UID FETCH, UID STORE commands). // ; * represents the largest number in use. In // ; the case of message sequence numbers, it is // ; the number of messages in a non-empty mailbox. // ; In the case of unique identifiers, it is the // ; unique identifier of the last message in the // ; mailbox or, if the mailbox is empty, the // ; mailbox's current UIDNEXT value. // ; The server should respond with a tagged BAD // ; response to a command that uses a message // ; sequence number greater than the number of // ; messages in the selected mailbox. This // ; includes "*" if the selected mailbox is empty. for _, o := range outMessages { seqNum, err := m.db.UIDToServerSeq(context.Background(), m.selectedMailbox.ID, o.UID) if err != nil { logger.Error("unable to retrieve server seq", "error", err) return errInternalServerError } clientSeqNum := m.sessionTracker.EncodeSeqNum(seqNum) if clientSeqNum == 0 { // message does not exist from client pov continue } mw := w.CreateMessage(clientSeqNum) if options.UID { mw.WriteUID(imap.UID(o.UID)) } if options.InternalDate { t, _ := time.Parse(time.RFC3339, o.InternalDate) mw.WriteInternalDate(t) } if options.RFC822Size { mw.WriteRFC822Size(o.RFC822Size) } if options.Envelope { fromAddresses, err := parseAddressList(o.EnvelopeFrom.String) if err != nil { logger.Error("unable to parse envelope FROM addresses", "error", err) return errInternalServerError } senderAddresses, err := parseAddressList(o.EnvelopeSender.String) if err != nil { logger.Error("unable to parse envelope SENDER addresses", "error", err) return errInternalServerError } // RFC: If the Sender or Reply-To header fields are absent in the [RFC5322] header, or are present but empty, // the server sets the corresponding member of the envelope to be the same value as the from member // (the client is not expected to know how to do this). if len(senderAddresses) == 0 { senderAddresses = fromAddresses } replyToAddresses, err := parseAddressList(o.EnvelopeReplyTo.String) if err != nil { logger.Error("unable to parse envelope REPLY TO addresses", "error", err) return errInternalServerError } if len(replyToAddresses) == 0 { replyToAddresses = fromAddresses } toAddresses, err := parseAddressList(o.EnvelopeTo.String) if err != nil { logger.Error("unable to parse envelope TO addresses", "error", err) return errInternalServerError } ccAddresses, err := parseAddressList(o.EnvelopeCc.String) if err != nil { logger.Error("unable to parse envelope CC addresses", "error", err) return errInternalServerError } bccAddresses, err := parseAddressList(o.EnvelopeBcc.String) if err != nil { logger.Error("unable to parse envelope BCC addresses", "error", err) return errInternalServerError } var t time.Time if o.EnvelopeDate.Valid { t, _ = time.Parse(time.RFC3339, o.EnvelopeDate.String) } // imap-go: The In-Reply-To and Message-ID values contain message identifiers without angle brackets. inReplyToAddresses := make([]string, 0) for a := range strings.SplitSeq(o.EnvelopeInReplyTo.String, ",") { inReplyTo, _ := strings.CutPrefix(a, "<") inReplyTo, _ = strings.CutSuffix(inReplyTo, ">") inReplyToAddresses = append(inReplyToAddresses, inReplyTo) } messageID, _ := strings.CutPrefix(o.EnvelopeMessageID.String, "<") messageID, _ = strings.CutSuffix(messageID, ">") mw.WriteEnvelope(&imap.Envelope{ Date: t, Subject: o.EnvelopeSubject.String, From: fromAddresses, Sender: senderAddresses, ReplyTo: replyToAddresses, To: toAddresses, Cc: ccAddresses, Bcc: bccAddresses, InReplyTo: inReplyToAddresses, MessageID: messageID, }) } // RFC 9051: The \Seen flag is implicitly set; if this causes the flags to change, they SHOULD be included as // part of the FETCH responses. mustShowFlags := false if options.BodyStructure != nil { r, err := m.persistence.BlobReader(o.BlobHash) if err != nil { logger.Error("unable to read blob", "error", err) return errInternalServerError } bs := imapserver.ExtractBodyStructure(r) err = r.Close() if err != nil { logger.Error("unable to close body structure", "error", err) } mw.WriteBodyStructure(bs) // Honestly not sure if flagging messages as \\Seen here is correct, or only below for BodySection if !slices.Contains(o.Flags, string(imap.FlagSeen)) { logger.Info("flag message as \\Seen", "uid", o.UID) _, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, o.UID, []string{string(imap.FlagSeen)}) if err != nil { logger.Error("unable to add message flags", "error", err) return errInternalServerError } o.Flags = append(o.Flags, string(imap.FlagSeen)) mustShowFlags = true } } for _, bs := range options.BodySection { if !bs.Peek && !m.selectedMailboxReadOnly { if !slices.Contains(o.Flags, string(imap.FlagSeen)) { logger.Info("flag message as \\Seen", "uid", o.UID) _, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, o.UID, []string{string(imap.FlagSeen)}) if err != nil { logger.Error("unable to add message flags", "error", err) return errInternalServerError } o.Flags = append(o.Flags, string(imap.FlagSeen)) mustShowFlags = true } } r, err := m.persistence.BlobReader(o.BlobHash) if err != nil { logger.Error("unable to read blob", "error", err) return errInternalServerError } ebs := imapserver.ExtractBodySection(r, bs) err = r.Close() if err != nil { logger.Error("unable to close blob reader", "error", err) } wc := mw.WriteBodySection(bs, int64(len(ebs))) _, writeErr := wc.Write(ebs) closeErr := wc.Close() if writeErr != nil { logger.Error("unable to write body section", "error", writeErr) return errInternalServerError } if closeErr != nil { logger.Error("unable to close body section", "error", closeErr) return errInternalServerError } } for _, bs := range options.BinarySection { if !bs.Peek && !m.selectedMailboxReadOnly { _, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, o.UID, []string{string(imap.FlagSeen)}) if err != nil { logger.Error("unable to add message flags", "error", err) return errInternalServerError } } r, err := m.persistence.BlobReader(o.BlobHash) if err != nil { logger.Error("unable to read blob", "error", err) return errInternalServerError } buf := imapserver.ExtractBinarySection(r, bs) err = r.Close() if err != nil { logger.Error("unable to close blob reader", "error", err) } wc := mw.WriteBinarySection(bs, int64(len(buf))) _, writeErr := wc.Write(buf) closeErr := wc.Close() if writeErr != nil { logger.Error("unable to write body section", "error", writeErr) return errInternalServerError } if closeErr != nil { logger.Error("unable to close body section", "error", closeErr) return errInternalServerError } } for _, bss := range options.BinarySectionSize { r, err := m.persistence.BlobReader(o.BlobHash) if err != nil { logger.Error("unable to read blob", "error", err) return errInternalServerError } n := imapserver.ExtractBinarySectionSize(r, bss) err = r.Close() if err != nil { logger.Error("unable to close blob reader", "error", err) } mw.WriteBinarySectionSize(bss, n) } // Flags should come last so that the updated \Seen flag is properly communicated if options.Flags || mustShowFlags { mw.WriteFlags(toIMAPFlags(o.Flags)) } if err = mw.Close(); err != nil { logger.Error("unable to close imap writer", "error", err) return errInternalServerError } } slog.Info("end FETCH") return nil } // Store SELECTED state func (m *imapSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, _ *imap.StoreOptions) error { logger := slog.With("session_id", m.sessionID, "cmd", "STORE", "user_id", m.user.ID) logger.Info("start STORE", "num_set", numSet, "flags", flags) if m.selectedMailbox.ID == -1 { logger.Info("no mailbox selected") return errNoMailboxSelected } if m.selectedMailboxReadOnly { logger.Info("mailbox selected as read-only") return errMailboxReadOnly } for _, f := range flags.Flags { if !isAllowedFlag(f) { logger.Info("flag not allowed", "flag", f) return errBadFlag } } sourceUIDs, err := m.resolveUIDs(context.Background(), numSet) if err != nil { if errors.Is(err, errDBSequenceOutOfRange) { logger.Info("sequence out of range") return errSequenceOutOfRange } logger.Error("unable to resolve UIDs", "error", err) return errInternalServerError } for _, sourceUID := range sourceUIDs { // Filter non-existing UIDs to prevent FK constraint errors.go and comply // with IMAP semantics where STORE on non-existent messages is a no-op. exists, err := m.db.UIDExists(context.Background(), m.user.ID, sourceUID) if err != nil { logger.Error("unable to retrieve UID", "error", err) return errInternalServerError } if !exists { continue } var flagSet []string switch flags.Op { case imap.StoreFlagsAdd: flagSet, err = m.db.AddMessageFlags(context.Background(), m.selectedMailbox.ID, sourceUID, fromIMAPFlags(flags.Flags)) case imap.StoreFlagsDel: flagSet, err = m.db.DeleteMessageFlags(context.Background(), m.selectedMailbox.ID, sourceUID, fromIMAPFlags(flags.Flags)) case imap.StoreFlagsSet: flagSet, err = m.db.SetMessageFlags(context.Background(), m.selectedMailbox.ID, sourceUID, fromIMAPFlags(flags.Flags)) default: logger.Error("unknown flag op", "flag", flags.Op) return &imap.Error{ Type: imap.StatusResponseTypeBad, Code: imap.ResponseCodeClientBug, Text: fmt.Sprintf("Unknown store flags: %v", flags.Op), } } if err != nil { logger.Error("unable to store flags", "error", err) return errInternalServerError } seqNum, err := m.db.UIDToServerSeq(context.Background(), m.selectedMailbox.ID, sourceUID) if err != nil { logger.Error("unable to convert to server seq", "error", err) return errInternalServerError } imapFlags := toIMAPFlags(flagSet) if !flags.Silent { fetchWriter := w.CreateMessage(m.sessionTracker.EncodeSeqNum(seqNum)) fetchWriter.WriteFlags(imapFlags) err = fetchWriter.Close() if err != nil { logger.Error("unable to close imap writer", "error", err) return errInternalServerError } } m.mTracker.get(m.user.ID, m.selectedMailbox.ID).QueueMessageFlags(seqNum, imap.UID(sourceUID), imapFlags, m.sessionTracker) } logger.Info("end STORE") return nil } func (m *imapSession) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) { logger := slog.With("session_id", m.sessionID, "cmd", "COPY", "user_id", m.user.ID) logger.Info("start COPY", "num_set", numSet, "dest", dest) if m.selectedMailbox.ID == -1 { logger.Info("no mailbox selected") return nil, errNoMailboxSelected } // must find destination mailbox, otherwise return failure (see RFC) mailboxID, uidValidity, err := m.db.GetMailboxID(context.Background(), m.user.ID, dest) if err != nil { if errors.Is(err, db.ErrMailboxNotFound) { logger.Info("destination mailbox not found") return nil, errMailboxNotExist } logger.Error("unable to get mailbox ID", "error", err) return nil, errInternalServerError } sourceUIDs, err := m.resolveUIDs(context.Background(), numSet) if err != nil { if errors.Is(err, errDBSequenceOutOfRange) { logger.Info("sequence out of range") return nil, errSequenceOutOfRange } logger.Error("unable to resolve UIDs", "error", err) return nil, errInternalServerError } // Filter non-existing UIDs to prevent copyMessagesToMailbox from burning // destination UIDs for messages that don't exist in the source mailbox. filteredSourceUIDs := make([]uint32, 0, len(sourceUIDs)) for _, uid := range sourceUIDs { exists, err := m.db.UIDExists(context.Background(), m.user.ID, uid) if err != nil { logger.Error("unable to retrieve UID", "error", err) return nil, errInternalServerError } if exists { filteredSourceUIDs = append(filteredSourceUIDs, uid) } } destUIDs, err := m.db.CopyMessagesToMailbox(context.Background(), m.selectedMailbox.ID, mailboxID, filteredSourceUIDs) if err != nil { logger.Error("unable to copy messages to mailbox", "error", err) return nil, errInternalServerError } logger.Info("end COPY") return &imap.CopyData{ UIDValidity: uidValidity, SourceUIDs: internalIDToUIDSet(filteredSourceUIDs), DestUIDs: internalIDToUIDSet(destUIDs), }, nil } func (m *imapSession) Namespace() (*imap.NamespaceData, error) { logger := slog.With("session_id", m.sessionID, "cmd", "NAMESPACE", "user_id", m.user.ID) logger.Info("start NAMESPACE") logger.Info("end NAMESPACE") return &imap.NamespaceData{ Personal: []imap.NamespaceDescriptor{ { Prefix: "", Delim: '/', }, }, Other: nil, Shared: nil, }, nil } // Move SELECTED state func (m *imapSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest string) error { ctx := context.Background() logger := slog.With("session_id", m.sessionID, "cmd", "MOVE", "user_id", m.user.ID) logger.InfoContext(ctx, "start MOVE", "num_set", numSet, "dest", dest) if m.selectedMailbox.ID == -1 { logger.InfoContext(ctx, "no mailbox selected") return errNoMailboxSelected } if m.selectedMailboxReadOnly { logger.InfoContext(ctx, "mailbox selected as read-only") return errMailboxReadOnly } // must find destination mailbox, otherwise return failure (see RFC) destMailboxID, uidValidity, err := m.db.GetMailboxID(ctx, m.user.ID, dest) if err != nil { if errors.Is(err, db.ErrMailboxNotFound) { logger.InfoContext(ctx, "destination mailbox not found") return errMailboxNotExist } logger.ErrorContext(ctx, "unable to get mailbox ID", "error", err) return errInternalServerError } if m.selectedMailbox.ID == destMailboxID { logger.InfoContext(ctx, "moving messages within the same mailbox is not supported") return &imap.Error{ Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeCannot, Text: "messages already moved", } } sourceUIDs, err := m.resolveUIDs(ctx, numSet) if err != nil { if errors.Is(err, errDBSequenceOutOfRange) { logger.InfoContext(ctx, "sequence out of range") return errSequenceOutOfRange } logger.ErrorContext(ctx, "unable to resolve UIDs", "error", err) return errInternalServerError } // Filter non-existing UIDs to prevent burning destination UIDs filteredSourceUIDs := make([]uint32, 0, len(sourceUIDs)) for _, uid := range sourceUIDs { exists, err := m.db.UIDExists(ctx, m.user.ID, uid) if err != nil { logger.ErrorContext(ctx, "unable to retrieve UID", "error", err) return errInternalServerError } if exists { filteredSourceUIDs = append(filteredSourceUIDs, uid) } } if len(filteredSourceUIDs) == 0 { logger.InfoContext(ctx, "no messages to move") return nil } // for expunging later, we need to calculate the serverSeq now expungeServerSeq := make([]uint32, len(filteredSourceUIDs)) for i, uid := range filteredSourceUIDs { serverSeq, err := m.db.UIDToServerSeq(ctx, m.selectedMailbox.ID, uid) if err != nil { logger.ErrorContext(ctx, "unable to convert to server seq", "error", err) return errInternalServerError } expungeServerSeq[i] = serverSeq } // Fetch message envelopes BEFORE the move (after the move, dest UIDs are // reassigned and source UIDs no longer resolve to live rows). movedMessages, err := m.db.GetMailboxMessagesByUID(ctx, m.user.ID, m.selectedMailbox.ID, filteredSourceUIDs) if err != nil { logger.ErrorContext(ctx, "unable to fetch messages for policy hook", "error", err) return errInternalServerError } destUIDs, err := m.db.MoveMessagesToMailbox(ctx, m.selectedMailbox.ID, destMailboxID, filteredSourceUIDs) if err != nil { logger.ErrorContext(ctx, "unable to move messages to mailbox", "error", err) return errInternalServerError } // Fire the on_message_moved policy hook for each moved message. The move // has already succeeded, so policy errors are logged but do not fail the // MOVE command. policyUser := policy.UserToStarlark(m.user, m.db) for _, mv := range movedMessages { msg := policy.MessageContextFromEnvelope(mv.EnvelopeFrom.String, mv.EnvelopeSubject.String, mv.RFC822Size) if err := m.policyEngine.OnMessageMoved(policyUser, msg, m.selectedMailbox.Name, dest); err != nil { logger.ErrorContext(ctx, "on_message_moved policy hook error", "error", err, "uid", mv.UID) } } // RFC 9051: Servers are also REQUIRED to send the COPYUID response code in an untagged OK before sending // EXPUNGE or similar responses. err = w.WriteCopyData(&imap.CopyData{ UIDValidity: uidValidity, SourceUIDs: internalIDToUIDSet(filteredSourceUIDs), DestUIDs: internalIDToUIDSet(destUIDs), }) if err != nil { logger.ErrorContext(ctx, "unable to write copy data", "error", err) return errInternalServerError } slices.Reverse(expungeServerSeq) for _, serverSeq := range expungeServerSeq { clientSeq := m.sessionTracker.EncodeSeqNum(serverSeq) if clientSeq == 0 { continue } m.mTracker.get(m.user.ID, m.selectedMailbox.ID).QueueExpunge(clientSeq) } destMsgCount, err := m.db.MailboxMessageCount(ctx, destMailboxID) if err != nil { logger.ErrorContext(ctx, "unable to get mailbox message count", "error", err) return errInternalServerError } destMailboxTracker := m.mTracker.getMailboxTracker(m.user.ID, destMailboxID, destMsgCount) destMailboxTracker.QueueNumMessages(destMsgCount) logger.Info("end MOVE") return nil } func internalIDToUIDSet(internalID []uint32) imap.UIDSet { out := imap.UIDSet{} for _, i := range internalID { out.AddNum(imap.UID(i)) } if len(out) == 0 { return nil // empty UIDSets are not allowed } return out } func resolveRange[T ~uint32](start, stop, last T) (T, T) { if start == 0 { start = last } if stop == 0 { stop = last } return start, stop } // resolveUIDs resolves a given imap.NumSet to the internal representation of imap UIDs which is uint32 func (m *imapSession) resolveUIDs(ctx context.Context, numSet imap.NumSet) ([]uint32, error) { staticSet, err := m.resolveDynamicSet(ctx, numSet) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } switch v := staticSet.(type) { case imap.UIDSet: nums, ok := v.Nums() if !ok { // This should theoretically not happen after dynamic resolution, // but we handle it defensively. return nil, fmt.Errorf("unresolvable UIDSet") } out := make([]uint32, len(nums)) for i, num := range nums { out[i] = uint32(num) } return out, nil case imap.SeqSet: nums, ok := v.Nums() if !ok { return nil, fmt.Errorf("unresolvable SeqSet") } uids := make([]uint32, 0, len(nums)) for _, num := range nums { uid, err := m.db.ServerSeqToUID(ctx, m.selectedMailbox.ID, m.sessionTracker.DecodeSeqNum(num)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errDBSequenceOutOfRange } return nil, err } uids = append(uids, uid) } return uids, nil } return nil, fmt.Errorf("unsupported NumSet type %T", numSet) } // resolveDynamicSet resolves the NumSet to the client view, so it can be converted later to the server view func (m *imapSession) resolveDynamicSet(ctx context.Context, numSet imap.NumSet) (imap.NumSet, error) { if !numSet.Dynamic() { return numSet, nil } if numSet.String() == "$" { return m.searchRes, nil } switch v := numSet.(type) { case imap.UIDSet: last, err := m.db.GetLastUID(ctx, m.user.ID, m.selectedMailbox.ID) if err != nil { return nil, err } if last == 0 { return imap.UIDSet{}, nil // empty mailbox: UID commands match nothing } out := imap.UIDSet{} for _, r := range v { start, stop := resolveRange(r.Start, r.Stop, last) out.AddRange(start, stop) } return out, nil case imap.SeqSet: last, err := m.db.MailboxMessageCount(ctx, m.selectedMailbox.ID) if err != nil { return nil, err } if last == 0 { return imap.SeqSet{}, nil // empty mailbox: "*" is 0, so any seq set matches nothing } out := imap.SeqSet{} for _, r := range v { start, stop := resolveRange(r.Start, r.Stop, m.sessionTracker.EncodeSeqNum(last)) out.AddRange(start, stop) } return out, nil default: return numSet, nil } }