package imapserver import ( "errors" "net/mail" "sort" "strings" "github.com/emersion/go-imap/v2" goimapserver "github.com/emersion/go-imap/v2/imapserver" "mailgoserver/internal/abuseguard" "mailgoserver/internal/db" ) const inboxName = "INBOX" var _ goimapserver.Session = (*Session)(nil) // Session implements goimapserver.Session against one mailbox's messages via // mailstore. Sequence numbers are recomputed fresh from the DB on every command // rather than cached/tracked across concurrent updates. // ponytail: no MailboxTracker/IDLE push support — Idle just blocks until the client // sends DONE, so a connected client still gets new mail via NOOP/periodic re-SELECT, // just not an instant push. Add a tracker if that matters. type Session struct { backend *Backend peerIP string mailbox *db.Mailbox // set once Login succeeds selectedFolder string // set by Select; defaults to INBOX if empty } func (s *Session) Close() error { return nil } // Login accepts only an app password (esrv_mailbox_app_passwords) — never the // mailbox's own portal password, since IMAP AUTH has no interactive MFA step. func (s *Session) Login(username, password string) error { mbox, err := s.backend.DB.VerifyMailboxAppPassword(username, password) if err != nil { _ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Authentication error: "+err.Error()) abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return err } if mbox == nil { _ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Invalid credentials") abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP) return goimapserver.ErrAuthFailed } _ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, true, "Successful IMAP app-password authentication") s.mailbox = mbox return nil } func (s *Session) requireAuth() error { if s.mailbox == nil { return errors.New("not authenticated") } return nil } func notFoundErr() error { return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"} } func isInbox(name string) bool { return strings.EqualFold(name, inboxName) } // folderExists reports whether name is a real folder for this mailbox — INBOX always // is (even empty), any other name only if a filter rule's move_to_folder action has // actually delivered something there (see mailstore's ApplyRules). Returns the // canonical stored name (case as written in the DB) so callers use it consistently. func (s *Session) folderExists(name string) (string, bool, error) { if isInbox(name) { return inboxName, true, nil } folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID) if err != nil { return "", false, err } for _, f := range folders { if strings.EqualFold(f, name) { return f, true, nil } } return "", false, nil } // messages loads every stored message in the currently selected folder, ordered // ascending by UID — this ordering IS the sequence-number mapping (index+1 == seqNum). func (s *Session) messages() ([]db.MailboxMessage, error) { folder := s.selectedFolder if folder == "" { folder = inboxName } return s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder) } func (s *Session) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) { if err := s.requireAuth(); err != nil { return nil, err } folder, ok, err := s.folderExists(mailbox) if err != nil { return nil, err } if !ok { return nil, notFoundErr() } s.selectedFolder = folder msgs, err := s.messages() if err != nil { return nil, err } flagSet := map[imap.Flag]struct{}{} var firstUnseen uint32 for i, m := range msgs { for _, f := range splitFlags(m.Flags) { flagSet[f] = struct{}{} } if firstUnseen == 0 && !hasFlag(m.Flags, imap.FlagSeen) { firstUnseen = uint32(i) + 1 } } var flags []imap.Flag for f := range flagSet { flags = append(flags, f) } sort.Slice(flags, func(i, j int) bool { return flags[i] < flags[j] }) permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard) return &imap.SelectData{ Flags: flags, PermanentFlags: permanent, NumMessages: uint32(len(msgs)), FirstUnseenSeqNum: firstUnseen, UIDNext: nextUID(msgs), UIDValidity: uint32(s.mailbox.ID), }, nil } func (s *Session) Unselect() error { s.selectedFolder = "" return nil } func (s *Session) Create(mailbox string, options *imap.CreateOptions) error { return errors.New("creating mailboxes is not supported") } func (s *Session) Delete(mailbox string) error { return errors.New("deleting mailboxes is not supported") } func (s *Session) Rename(mailbox, newName string, options *imap.RenameOptions) error { return errors.New("renaming mailboxes is not supported") } func (s *Session) Subscribe(mailbox string) error { _, ok, err := s.folderExists(mailbox) if err != nil { return err } if !ok { return notFoundErr() } return nil } func (s *Session) Unsubscribe(mailbox string) error { _, ok, err := s.folderExists(mailbox) if err != nil { return err } if !ok { return notFoundErr() } return nil } // List reports every folder that actually has mail (plus INBOX, always) — a filter // rule's move_to_folder action is what creates a second folder; there's no IMAP // CREATE/manual folder management. func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { if err := s.requireAuth(); err != nil { return err } folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID) if err != nil { return err } if len(patterns) == 0 { patterns = []string{""} } for _, pattern := range patterns { if pattern == "" { continue } for _, folder := range folders { if !goimapserver.MatchList(folder, '/', ref, pattern) { continue } if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/'}); err != nil { return err } } } return nil } func (s *Session) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) { if err := s.requireAuth(); err != nil { return nil, err } folder, ok, err := s.folderExists(mailbox) if err != nil { return nil, err } if !ok { return nil, notFoundErr() } msgs, err := s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder) if err != nil { return nil, err } data := &imap.StatusData{Mailbox: folder, UIDValidity: uint32(s.mailbox.ID), UIDNext: nextUID(msgs)} if options.NumMessages { n := uint32(len(msgs)) data.NumMessages = &n } if options.NumUnseen { var n uint32 for _, m := range msgs { if !hasFlag(m.Flags, imap.FlagSeen) { n++ } } data.NumUnseen = &n } if options.Size { var size int64 for _, m := range msgs { size += m.SizeBytes } data.Size = &size } return data, nil } func (s *Session) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { return nil, errors.New("APPEND is not supported yet") } func (s *Session) Poll(w *goimapserver.UpdateWriter, allowExpunge bool) error { return nil } func (s *Session) Idle(w *goimapserver.UpdateWriter, stop <-chan struct{}) error { <-stop return nil } // Expunge permanently removes every \Deleted-flagged message matched by uids (or all // of them, if uids is nil) — this is how a client actually deletes mail (STORE // \Deleted, then EXPUNGE), calling all the way down to mailstore so the on-disk // ciphertext is removed too, not just the index row. func (s *Session) Expunge(w *goimapserver.ExpungeWriter, uids *imap.UIDSet) error { if err := s.requireAuth(); err != nil { return err } msgs, err := s.messages() if err != nil { return err } for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if uids != nil && !uids.Contains(imap.UID(m.ID)) { continue } if !hasFlag(m.Flags, imap.FlagDeleted) { continue } if err := s.backend.Mailstore.DeleteMessage(s.mailbox.ID, m.ID); err != nil { return err } if err := w.WriteExpunge(uint32(i) + 1); err != nil { return err } } return nil } // Search supports structural criteria (sequence/UID sets, flags, size, boolean // combinators) without decrypting anything. // ponytail: no header/body/text/date matching (would require decrypting every // candidate message) — SEARCH FROM/SUBJECT/BODY/SINCE etc. are treated as always // matching rather than filtering. Add if a client's search relies on it. func (s *Session) Search(kind goimapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { if err := s.requireAuth(); err != nil { return nil, err } msgs, err := s.messages() if err != nil { return nil, err } var data imap.SearchData var seqSet imap.SeqSet var uidSet imap.UIDSet for i, m := range msgs { seqNum := uint32(i) + 1 if !matchesSearch(m, seqNum, criteria) { continue } uidSet.AddNum(imap.UID(m.ID)) seqSet.AddNum(seqNum) data.Count++ num := seqNum if kind == goimapserver.NumKindUID { num = uint32(m.ID) } if data.Min == 0 || num < data.Min { data.Min = num } if num > data.Max { data.Max = num } } if kind == goimapserver.NumKindUID { data.All = uidSet } else { data.All = seqSet } return &data, nil } func matchesSearch(m db.MailboxMessage, seqNum uint32, c *imap.SearchCriteria) bool { if c == nil { return true } for _, ss := range c.SeqNum { if !ss.Contains(seqNum) { return false } } for _, us := range c.UID { if !us.Contains(imap.UID(m.ID)) { return false } } for _, f := range c.Flag { if !hasFlag(m.Flags, f) { return false } } for _, f := range c.NotFlag { if hasFlag(m.Flags, f) { return false } } if c.Larger > 0 && m.SizeBytes <= c.Larger { return false } if c.Smaller > 0 && m.SizeBytes >= c.Smaller { return false } for _, not := range c.Not { if matchesSearch(m, seqNum, ¬) { return false } } for _, or := range c.Or { if !matchesSearch(m, seqNum, &or[0]) && !matchesSearch(m, seqNum, &or[1]) { return false } } return true } // Fetch streams FETCH responses for every message matched by numSet. Fetching a // non-peek body section marks the message \Seen, mirroring standard IMAP semantics. func (s *Session) Fetch(w *goimapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { if err := s.requireAuth(); err != nil { return err } msgs, err := s.messages() if err != nil { return err } markSeen := false for _, bs := range options.BodySection { if !bs.Peek { markSeen = true } } for i, m := range msgs { seqNum := uint32(i) + 1 if !numSetContains(numSet, seqNum, imap.UID(m.ID)) { continue } if markSeen && !hasFlag(m.Flags, imap.FlagSeen) { newFlags := addFlag(m.Flags, imap.FlagSeen) if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil { return err } m.Flags = newFlags } if err := s.writeFetch(w.CreateMessage(seqNum), m, options); err != nil { return err } } return nil } // writeFetch writes one message's FETCH response. // ponytail: no MIME-aware sub-part/header-only body-section extraction — any // requested BODY[...] section returns the full raw RFC822 message regardless of the // requested part/specifier. Real clients' basic "fetch the whole message" flow works // fine with this; upgrade to real section extraction if header-only/sub-part fetches // are needed later. func (s *Session) writeFetch(w *goimapserver.FetchResponseWriter, m db.MailboxMessage, options *imap.FetchOptions) error { w.WriteUID(imap.UID(m.ID)) if options.Flags { w.WriteFlags(splitFlags(m.Flags)) } if options.InternalDate { w.WriteInternalDate(m.InternalDate) } if options.RFC822Size { w.WriteRFC822Size(m.SizeBytes) } if options.Envelope { w.WriteEnvelope(buildEnvelope(m)) } if len(options.BodySection) > 0 { raw, err := s.backend.Mailstore.FetchMessage(s.mailbox.ID, m.ID) if err != nil { return err } for _, bs := range options.BodySection { wc := w.WriteBodySection(bs, int64(len(raw))) if _, werr := wc.Write(raw); werr != nil { wc.Close() return werr } if cerr := wc.Close(); cerr != nil { return cerr } } } return w.Close() } func buildEnvelope(m db.MailboxMessage) *imap.Envelope { env := &imap.Envelope{Date: m.InternalDate, Subject: m.CachedSubject, MessageID: m.MessageIDHeader} if addr, err := mail.ParseAddress(m.CachedFrom); err == nil { mailbox, host := splitAddr(addr.Address) env.From = []imap.Address{{Name: addr.Name, Mailbox: mailbox, Host: host}} } return env } func splitAddr(addr string) (mailbox, host string) { i := strings.LastIndex(addr, "@") if i < 0 { return addr, "" } return addr[:i], addr[i+1:] } // Store applies a flag change to every message matched by numSet, then (unless // .SILENT was requested) reports the resulting flags back via a FETCH response, // mirroring standard STORE semantics. func (s *Session) Store(w *goimapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { if err := s.requireAuth(); err != nil { return err } msgs, err := s.messages() if err != nil { return err } for i, m := range msgs { seqNum := uint32(i) + 1 if !numSetContains(numSet, seqNum, imap.UID(m.ID)) { continue } newFlags := applyStoreFlags(m.Flags, flags) if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil { return err } } if flags.Silent { return nil } return s.Fetch(w, numSet, &imap.FetchOptions{Flags: true}) } func (s *Session) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) { return nil, errors.New("COPY is not supported") } func nextUID(msgs []db.MailboxMessage) imap.UID { if len(msgs) == 0 { return 1 } return imap.UID(msgs[len(msgs)-1].ID) + 1 } func numSetContains(numSet imap.NumSet, seqNum uint32, uid imap.UID) bool { switch ns := numSet.(type) { case imap.SeqSet: return ns.Contains(seqNum) case imap.UIDSet: return ns.Contains(uid) } return false } func splitFlags(s string) []imap.Flag { if s == "" { return nil } parts := strings.Fields(s) out := make([]imap.Flag, len(parts)) for i, p := range parts { out[i] = imap.Flag(p) } return out } func hasFlag(flags string, target imap.Flag) bool { for _, f := range strings.Fields(flags) { if imap.Flag(f) == target { return true } } return false } func addFlag(flags string, f imap.Flag) string { if hasFlag(flags, f) { return flags } if flags == "" { return string(f) } return flags + " " + string(f) } func applyStoreFlags(current string, store *imap.StoreFlags) string { set := map[imap.Flag]struct{}{} for _, f := range splitFlags(current) { set[f] = struct{}{} } switch store.Op { case imap.StoreFlagsSet: set = map[imap.Flag]struct{}{} fallthrough case imap.StoreFlagsAdd: for _, f := range store.Flags { set[f] = struct{}{} } case imap.StoreFlagsDel: for _, f := range store.Flags { delete(set, f) } } out := make([]string, 0, len(set)) for f := range set { out = append(out, string(f)) } sort.Strings(out) return strings.Join(out, " ") }