package imap import ( "bufio" "bytes" "context" "fmt" "log" "sort" "strings" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapserver" "github.com/emersion/go-message/textproto" "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" ) const mailboxDelim rune = '/' // msgEntry holds the in-memory descriptor for one message in the selected mailbox. type msgEntry struct { dbID int64 uid imap.UID isRead bool isStarred bool isDraft bool isDeleted bool extraFlags string size int64 internalDate time.Time } func (e *msgEntry) flagList() []imap.Flag { var flags []imap.Flag if e.isRead { flags = append(flags, imap.FlagSeen) } if e.isStarred { flags = append(flags, imap.FlagFlagged) } if e.isDraft { flags = append(flags, imap.FlagDraft) } if e.isDeleted { flags = append(flags, imap.FlagDeleted) } for _, f := range strings.Fields(e.extraFlags) { flags = append(flags, imap.Flag(f)) } return flags } // IMAPSession implements imapserver.SessionIMAP4rev2. type IMAPSession struct { deps *Deps clientIP string user *models.User // set after Login selectedMailbox *models.Mailbox msgs []msgEntry // index+1 = IMAP sequence number mboxTracker *imapserver.MailboxTracker sessionTracker *imapserver.SessionTracker } var _ imapserver.SessionIMAP4rev2 = (*IMAPSession)(nil) // ---- Authentication ---- func (s *IMAPSession) Close() error { if s.sessionTracker != nil { s.sessionTracker.Close() } return nil } func (s *IMAPSession) Login(username, password string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() user, err := s.deps.DB.GetUserByEmail(ctx, username) if err != nil { log.Printf("[imap] login error %s: %v", username, err) return imapserver.ErrAuthFailed } if user == nil || !user.Enabled { s.recordAttempt(ctx, username, false) return imapserver.ErrAuthFailed } if err := crypto.CheckPassword(user.PasswordHash, password); err != nil { log.Printf("[imap] auth failed %s from %s", username, s.clientIP) s.recordAttempt(ctx, username, false) return imapserver.ErrAuthFailed } s.user = user s.deps.DB.UpdateLastLogin(ctx, user.ID) //nolint:errcheck s.recordAttempt(ctx, username, true) log.Printf("[imap] auth OK %s from %s", username, s.clientIP) return nil } func (s *IMAPSession) recordAttempt(ctx context.Context, email string, success bool) { if s.deps.Brute != nil { s.deps.Brute.RecordAttempt(ctx, s.clientIP, email, success) //nolint:errcheck } } // ---- Mailbox management ---- func (s *IMAPSession) Select(mailboxName string, options *imap.SelectOptions) (*imap.SelectData, error) { s.doUnselect() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() mbox, err := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) if err != nil { return nil, imapErr(err) } if mbox == nil { return nil, noSuchMailbox() } msgs, err := s.deps.DB.ListIMAPMessages(ctx, mbox.ID) if err != nil { return nil, imapErr(err) } s.selectedMailbox = mbox s.msgs = make([]msgEntry, 0, len(msgs)) for _, m := range msgs { s.msgs = append(s.msgs, msgEntry{ dbID: m.ID, uid: imap.UID(m.UID), isRead: m.IsRead, isStarred: m.IsStarred, isDraft: m.IsDraft, extraFlags: m.Flags, size: m.SizeBytes, internalDate: m.ReceivedAt, }) } s.mboxTracker = imapserver.NewMailboxTracker(uint32(len(s.msgs))) s.sessionTracker = s.mboxTracker.NewSession() var firstUnseen uint32 for i, m := range s.msgs { if !m.isRead { firstUnseen = uint32(i + 1) break } } return &imap.SelectData{ Flags: allFlags(), PermanentFlags: allFlags(), NumMessages: uint32(len(s.msgs)), FirstUnseenSeqNum: firstUnseen, UIDNext: imap.UID(mbox.UIDNext), UIDValidity: mbox.UIDValidity, List: &imap.ListData{ Mailbox: mailboxName, Delim: mailboxDelim, Attrs: mboxAttrs(mbox), }, }, nil } func (s *IMAPSession) Unselect() error { s.doUnselect() return nil } func (s *IMAPSession) doUnselect() { if s.sessionTracker != nil { s.sessionTracker.Close() s.sessionTracker = nil } s.selectedMailbox = nil s.msgs = nil s.mboxTracker = nil } func (s *IMAPSession) Create(mailboxName string, options *imap.CreateOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() existing, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) if existing != nil { return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeAlreadyExists, Text: "Mailbox already exists"} } _, err := s.deps.DB.CreateMailbox(ctx, s.user.ID, mailboxName, "", nil) return imapErr(err) } func (s *IMAPSession) Delete(mailboxName string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) if mbox == nil { return noSuchMailbox() } if mbox.Type != "" { return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Cannot delete system mailbox"} } _, err := s.deps.DB.SQL().ExecContext(ctx, "DELETE FROM mailboxes WHERE id=?", mbox.ID) return imapErr(err) } func (s *IMAPSession) Rename(oldName, newName string, options *imap.RenameOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, oldName) if mbox == nil { return noSuchMailbox() } return imapErr(s.deps.DB.RenameMailbox(ctx, mbox.ID, newName)) } func (s *IMAPSession) Subscribe(mailboxName string) error { return s.setSubscribed(mailboxName, true) } func (s *IMAPSession) Unsubscribe(mailboxName string) error { return s.setSubscribed(mailboxName, false) } func (s *IMAPSession) setSubscribed(name string, sub bool) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, name) if mbox == nil { return noSuchMailbox() } return imapErr(s.deps.DB.SetMailboxSubscribed(ctx, mbox.ID, sub)) } func (s *IMAPSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if len(patterns) == 0 { return w.WriteList(&imap.ListData{ Attrs: []imap.MailboxAttr{imap.MailboxAttrNoSelect}, Delim: mailboxDelim, }) } mailboxes, err := s.deps.DB.ListMailboxes(ctx, s.user.ID) if err != nil { return imapErr(err) } type entry struct { name string data imap.ListData } var entries []entry for _, mbox := range mailboxes { if options.SelectSubscribed && !mbox.Subscribed { continue } matched := false for _, pat := range patterns { if imapserver.MatchList(mbox.Name, mailboxDelim, ref, pat) { matched = true break } } if !matched { continue } data := imap.ListData{ Mailbox: mbox.Name, Delim: mailboxDelim, Attrs: mboxAttrs(mbox), } if mbox.Subscribed { data.Attrs = append(data.Attrs, imap.MailboxAttrSubscribed) } if options.ReturnStatus != nil { sd, _ := s.statusFor(ctx, mbox, options.ReturnStatus) data.Status = sd } entries = append(entries, entry{name: mbox.Name, data: data}) } sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name }) for _, e := range entries { if err := w.WriteList(&e.data); err != nil { return err } } return nil } func (s *IMAPSession) Status(mailboxName string, options *imap.StatusOptions) (*imap.StatusData, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) if mbox == nil { return nil, noSuchMailbox() } return s.statusFor(ctx, mbox, options) } func (s *IMAPSession) statusFor(ctx context.Context, mbox *models.Mailbox, opts *imap.StatusOptions) (*imap.StatusData, error) { data := &imap.StatusData{Mailbox: mbox.Name} total, unseen, err := s.deps.DB.GetMailboxMessageCounts(ctx, mbox.ID) if err != nil { return nil, imapErr(err) } if opts.NumMessages { n := uint32(total) data.NumMessages = &n } if opts.NumUnseen { n := uint32(unseen) data.NumUnseen = &n } if opts.UIDNext { data.UIDNext = imap.UID(mbox.UIDNext) } if opts.UIDValidity { data.UIDValidity = mbox.UIDValidity } if opts.NumRecent { n := uint32(0) data.NumRecent = &n } if opts.NumDeleted { n := uint32(0) data.NumDeleted = &n } if opts.Size { sz, _ := s.deps.DB.GetMailboxSize(ctx, mbox.ID) data.Size = &sz } return data, nil } func (s *IMAPSession) Append(mailboxName string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() mbox, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, mailboxName) if mbox == nil { return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such mailbox"} } raw, err := readLiteral(r, s.deps.Cfg.MaxMessageSize) if err != nil { return nil, imapErr(err) } key, err := s.deps.Crypt.DeriveKey("messages", s.user.ID) if err != nil { return nil, imapErr(fmt.Errorf("derive key: %w", err)) } rawEnc, err := crypto.Encrypt(key, raw) if err != nil { return nil, imapErr(err) } uid, err := s.deps.DB.NextUID(ctx, mbox.ID) if err != nil { return nil, imapErr(err) } internalDate := time.Now().UTC() isRead, isDraft := false, false var extraParts []string if options != nil { if !options.Time.IsZero() { internalDate = options.Time } for _, f := range options.Flags { switch f { case imap.FlagSeen: isRead = true case imap.FlagDraft: isDraft = true default: extraParts = append(extraParts, string(f)) } } } ins := &db.MessageInsert{ MailboxID: mbox.ID, UID: uid, SizeBytes: int64(len(raw)), RawEnc: rawEnc, IsRead: isRead, IsDraft: isDraft, Flags: strings.Join(extraParts, " "), Date: internalDate, } if _, err := s.deps.DB.InsertMessage(ctx, ins); err != nil { return nil, imapErr(err) } return &imap.AppendData{ UIDValidity: mbox.UIDValidity, UID: imap.UID(uid), }, nil } func (s *IMAPSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error { if s.sessionTracker == nil { return nil } return s.sessionTracker.Poll(w, allowExpunge) } func (s *IMAPSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error { if s.sessionTracker == nil { return nil } return s.sessionTracker.Idle(w, stop) } // ---- Selected-state operations ---- func (s *IMAPSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error { if s.selectedMailbox == nil { return noSelectedMailbox() } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Soft-delete entries marked \Deleted (filtered by UIDs if provided). for i := range s.msgs { m := &s.msgs[i] if uids != nil && !uids.Contains(m.uid) { continue } if m.isDeleted { s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck } } deletedUIDs, err := s.deps.DB.HardDeleteMessages(ctx, s.selectedMailbox.ID) if err != nil { return imapErr(err) } deletedSet := make(map[imap.UID]struct{}, len(deletedUIDs)) for _, uid := range deletedUIDs { deletedSet[imap.UID(uid)] = struct{}{} } // Collect seq nums to expunge (in ascending order, write in reverse). var seqNums []uint32 for i, m := range s.msgs { if _, ok := deletedSet[m.uid]; ok { seqNums = append(seqNums, uint32(i+1)) } } for i := len(seqNums) - 1; i >= 0; i-- { if err := w.WriteExpunge(seqNums[i]); err != nil { return err } } // Remove from in-memory list. filtered := s.msgs[:0] for _, m := range s.msgs { if _, ok := deletedSet[m.uid]; !ok { filtered = append(filtered, m) } } s.msgs = filtered return nil } func (s *IMAPSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) { if s.selectedMailbox == nil { return nil, noSelectedMailbox() } if kind == imapserver.NumKindUID { var result imap.UIDSet for i, m := range s.msgs { seqNum := uint32(i + 1) if s.matchCriteria(seqNum, &m, criteria) { result.AddNum(m.uid) } } return &imap.SearchData{All: result}, nil } var result imap.SeqSet for i, m := range s.msgs { seqNum := uint32(i + 1) if s.matchCriteria(seqNum, &m, criteria) { result.AddNum(seqNum) } } return &imap.SearchData{All: result}, nil } func (s *IMAPSession) matchCriteria(seqNum uint32, m *msgEntry, c *imap.SearchCriteria) bool { if c == nil { return true } for _, seqSet := range c.SeqNum { if !seqSet.Contains(seqNum) { return false } } for _, uidSet := range c.UID { if !uidSet.Contains(m.uid) { return false } } flagSet := make(map[imap.Flag]struct{}) for _, f := range m.flagList() { flagSet[f] = struct{}{} } for _, f := range c.Flag { if _, ok := flagSet[f]; !ok { return false } } for _, f := range c.NotFlag { if _, ok := flagSet[f]; ok { return false } } if c.Larger != 0 && m.size <= c.Larger { return false } if c.Smaller != 0 && m.size >= c.Smaller { return false } if !matchDate(m.internalDate, c.Since, c.Before) { return false } for _, sub := range c.Not { if s.matchCriteria(seqNum, m, &sub) { return false } } for _, or := range c.Or { if !s.matchCriteria(seqNum, m, &or[0]) && !s.matchCriteria(seqNum, m, &or[1]) { return false } } return true } func (s *IMAPSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error { if s.selectedMailbox == nil { return noSelectedMailbox() } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() key, err := s.deps.Crypt.DeriveKey("messages", s.user.ID) if err != nil { return imapErr(err) } for i := range s.msgs { m := &s.msgs[i] seqNum := uint32(i + 1) if !numSetContains(numSet, seqNum, m.uid) { continue } rw := w.CreateMessage(seqNum) rw.WriteUID(m.uid) if options.Flags { rw.WriteFlags(m.flagList()) } if options.InternalDate { rw.WriteInternalDate(m.internalDate) } if options.RFC822Size { rw.WriteRFC822Size(m.size) } needRaw := options.Envelope || options.BodyStructure != nil || len(options.BodySection) > 0 || len(options.BinarySection) > 0 || len(options.BinarySectionSize) > 0 if needRaw { rawEnc, err := s.deps.DB.GetMessageRaw(ctx, m.dbID) if err != nil || rawEnc == nil { rw.Close() //nolint:errcheck continue } raw, err := crypto.Decrypt(key, rawEnc) if err != nil { log.Printf("[imap] decrypt %d: %v", m.dbID, err) rw.Close() //nolint:errcheck continue } if options.Envelope { if env := extractEnvelope(raw); env != nil { rw.WriteEnvelope(env) } } if options.BodyStructure != nil { rw.WriteBodyStructure(imapserver.ExtractBodyStructure(bytes.NewReader(raw))) } for _, bs := range options.BodySection { buf := imapserver.ExtractBodySection(bytes.NewReader(raw), bs) wc := rw.WriteBodySection(bs, int64(len(buf))) wc.Write(buf) //nolint:errcheck wc.Close() //nolint:errcheck } for _, bs := range options.BinarySection { buf := imapserver.ExtractBinarySection(bytes.NewReader(raw), bs) wc := rw.WriteBinarySection(bs, int64(len(buf))) wc.Write(buf) //nolint:errcheck wc.Close() //nolint:errcheck } for _, bss := range options.BinarySectionSize { n := imapserver.ExtractBinarySectionSize(bytes.NewReader(raw), bss) rw.WriteBinarySectionSize(bss, n) } } if err := rw.Close(); err != nil { return err } } return nil } func (s *IMAPSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error { if s.selectedMailbox == nil { return noSelectedMailbox() } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() for i := range s.msgs { m := &s.msgs[i] seqNum := uint32(i + 1) if !numSetContains(numSet, seqNum, m.uid) { continue } applyStoreFlags(m, flags) s.deps.DB.SetMessageFlags(ctx, m.dbID, m.isRead, m.isStarred, m.isDraft, m.extraFlags) //nolint:errcheck if m.isDeleted { s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck } if !flags.Silent { rw := w.CreateMessage(seqNum) rw.WriteUID(m.uid) rw.WriteFlags(m.flagList()) if err := rw.Close(); err != nil { return err } } } return nil } func (s *IMAPSession) Copy(numSet imap.NumSet, destName string) (*imap.CopyData, error) { if s.selectedMailbox == nil { return nil, noSelectedMailbox() } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() dest, _ := s.deps.DB.GetMailbox(ctx, s.user.ID, destName) if dest == nil { return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeTryCreate, Text: "No such mailbox"} } var sourceUIDs, destUIDs imap.UIDSet for i, m := range s.msgs { seqNum := uint32(i + 1) if !numSetContains(numSet, seqNum, m.uid) { continue } newUID, err := s.deps.DB.CopyMessageToMailbox(ctx, m.dbID, dest.ID, s.user.ID) if err != nil { log.Printf("[imap] copy msg %d: %v", m.dbID, err) continue } sourceUIDs.AddNum(m.uid) destUIDs.AddNum(imap.UID(newUID)) } return &imap.CopyData{ UIDValidity: dest.UIDValidity, SourceUIDs: sourceUIDs, DestUIDs: destUIDs, }, nil } func (s *IMAPSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, destName string) error { copyData, err := s.Copy(numSet, destName) if err != nil { return err } if err := w.WriteCopyData(copyData); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var seqNums []uint32 for i := range s.msgs { m := &s.msgs[i] seqNum := uint32(i + 1) if !numSetContains(numSet, seqNum, m.uid) { continue } s.deps.DB.SoftDeleteMessage(ctx, m.dbID) //nolint:errcheck seqNums = append(seqNums, seqNum) } s.deps.DB.HardDeleteMessages(ctx, s.selectedMailbox.ID) //nolint:errcheck for i := len(seqNums) - 1; i >= 0; i-- { if err := w.WriteExpunge(seqNums[i]); err != nil { return err } } // Remove moved messages. kept := s.msgs[:0] for i, m := range s.msgs { seqNum := uint32(i + 1) if !numSetContains(numSet, seqNum, m.uid) { kept = append(kept, m) } } s.msgs = kept return nil } func (s *IMAPSession) Namespace() (*imap.NamespaceData, error) { return &imap.NamespaceData{ Personal: []imap.NamespaceDescriptor{{Delim: mailboxDelim}}, }, nil } // ---- Helpers ---- // numSetContains checks seqNum (for SeqSet) or uid (for UIDSet). 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 applyStoreFlags(m *msgEntry, store *imap.StoreFlags) { flagMap := map[imap.Flag]*bool{ imap.FlagSeen: &m.isRead, imap.FlagFlagged: &m.isStarred, imap.FlagDraft: &m.isDraft, imap.FlagDeleted: &m.isDeleted, } switch store.Op { case imap.StoreFlagsSet: m.isRead, m.isStarred, m.isDraft, m.isDeleted = false, false, false, false m.extraFlags = "" for _, f := range store.Flags { if ptr, ok := flagMap[f]; ok { *ptr = true } else { m.extraFlags = strings.TrimSpace(m.extraFlags + " " + string(f)) } } case imap.StoreFlagsAdd: for _, f := range store.Flags { if ptr, ok := flagMap[f]; ok { *ptr = true } else if !strings.Contains(m.extraFlags, string(f)) { m.extraFlags = strings.TrimSpace(m.extraFlags + " " + string(f)) } } case imap.StoreFlagsDel: for _, f := range store.Flags { if ptr, ok := flagMap[f]; ok { *ptr = false } else { m.extraFlags = strings.TrimSpace(strings.ReplaceAll(m.extraFlags, string(f), "")) } } } } func allFlags() []imap.Flag { return []imap.Flag{ imap.FlagSeen, imap.FlagAnswered, imap.FlagFlagged, imap.FlagDeleted, imap.FlagDraft, } } func mboxAttrs(mbox *models.Mailbox) []imap.MailboxAttr { su := db.MailboxTypeToSpecialUse(mbox.Type) if su != "" { return []imap.MailboxAttr{imap.MailboxAttr(su)} } return nil } func extractEnvelope(raw []byte) *imap.Envelope { br := bufio.NewReader(bytes.NewReader(raw)) header, err := textproto.ReadHeader(br) if err != nil { return nil } return imapserver.ExtractEnvelope(header) } func matchDate(t, since, before time.Time) bool { t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) if !since.IsZero() && t.Before(since) { return false } if !before.IsZero() && !t.Before(before) { return false } return true } func readLiteral(r imap.LiteralReader, maxSize int64) ([]byte, error) { buf := make([]byte, 0, 4096) tmp := make([]byte, 32768) var total int64 for { n, err := r.Read(tmp) if n > 0 { total += int64(n) if total > maxSize { return nil, fmt.Errorf("message too large") } buf = append(buf, tmp[:n]...) } if err != nil { break } } return buf, nil } func imapErr(err error) error { if err == nil { return nil } if _, ok := err.(*imap.Error); ok { return err } return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "server error"} } func noSuchMailbox() error { return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"} } func noSelectedMailbox() error { return &imap.Error{Type: imap.StatusResponseTypeBad, Text: "no mailbox selected"} }