diff --git a/backend/listen/imap_server/server.go b/backend/listen/imap_server/server.go index ad7e3df..6c11277 100644 --- a/backend/listen/imap_server/server.go +++ b/backend/listen/imap_server/server.go @@ -52,6 +52,7 @@ type serverSession struct { ctx *context.Context status Status currentMailbox string + readOnly bool connectTime time.Time deleteUidList []int remoteAddress string @@ -277,5 +278,6 @@ func appendTooBigError() error { func (s *serverSession) Unselect() error { s.currentMailbox = "" + s.readOnly = false return nil } diff --git a/backend/listen/imap_server/session_fetch.go b/backend/listen/imap_server/session_fetch.go index 018496f..20adc6f 100644 --- a/backend/listen/imap_server/session_fetch.go +++ b/backend/listen/imap_server/session_fetch.go @@ -12,7 +12,6 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/dto/parsemail" "git.pcux.cn/xuans/Smail/dto/response" - "git.pcux.cn/xuans/Smail/services/detail" "git.pcux.cn/xuans/Smail/services/list" "git.pcux.cn/xuans/Smail/utils/context" "github.com/emersion/go-imap/v2" @@ -345,7 +344,7 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons } for _, section := range options.BodySection { if !section.Peek { - if err := detail.MakeRead(ctx, email.Id, true); err != nil { + if err := updateReadByUID(ctx.UserID, email.UeId, true); err != nil { log.WithContext(ctx).WithError(err).Error("mark IMAP message as read failed") return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Fetch failed"} } diff --git a/backend/listen/imap_server/session_list.go b/backend/listen/imap_server/session_list.go index 97a8d5e..9a3d26f 100644 --- a/backend/listen/imap_server/session_list.go +++ b/backend/listen/imap_server/session_list.go @@ -13,7 +13,7 @@ import ( func matchGroup(ctx *context.Context, w *imapserver.ListWriter, basePath, pattern string) error { var groups []*models.Group - if basePath == "" && pattern == "*" { + if basePath == "" && (pattern == "*" || pattern == "%") { if err := db.Instance.Table("group").Where("user_id=?", ctx.UserID).Find(&groups); err != nil { log.WithContext(ctx).WithError(err).Error("query IMAP mailbox list failed") return listFailedError() diff --git a/backend/listen/imap_server/session_move.go b/backend/listen/imap_server/session_move.go index 7d58e0d..73c5bee 100644 --- a/backend/listen/imap_server/session_move.go +++ b/backend/listen/imap_server/session_move.go @@ -43,30 +43,32 @@ func (s *serverSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest } } - mailIDs := make([]int, 0, len(emailList)) + relationIDs := make([]int, 0, len(emailList)) for _, email := range emailList { - mailIDs = append(mailIDs, email.Id) + if email.UeId > 0 { + relationIDs = append(relationIDs, email.UeId) + } } - if len(mailIDs) == 0 { + if len(relationIDs) == 0 { return nil } if group.IsDefaultBox(dest) { - return move2defaultbox(s.ctx, mailIDs, dest) + return move2defaultbox(s.ctx, relationIDs, dest) } - return move2userbox(s.ctx, mailIDs, dest) + return move2userbox(s.ctx, relationIDs, dest) } -func move2defaultbox(ctx *context.Context, mailIDs []int, dest string) error { - if err := group.Move2DefaultBox(ctx, mailIDs, dest); err != nil { +func move2defaultbox(ctx *context.Context, relationIDs []int, dest string) error { + if err := group.MoveRelationsToDefaultBox(ctx, relationIDs, dest); err != nil { log.WithContext(ctx).WithError(err).Error("move messages to default mailbox failed") return moveFailedError() } return nil } -func move2userbox(ctx *context.Context, mailIDs []int, dest string) error { +func move2userbox(ctx *context.Context, relationIDs []int, dest string) error { groupInfo, err := group.GetGroupByFullPath(ctx, dest) if err != nil { log.WithContext(ctx).WithError(err).Error("resolve IMAP move destination failed") @@ -75,7 +77,7 @@ func move2userbox(ctx *context.Context, mailIDs []int, dest string) error { if groupInfo == nil || groupInfo.ID == 0 { return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Mailbox not found"} } - if err := group.MoveMailToGroup(ctx, mailIDs, groupInfo.ID); err != nil { + if err := group.MoveRelationsToGroup(ctx, relationIDs, groupInfo.ID); err != nil { log.WithContext(ctx).Error("move messages to user mailbox failed") return moveFailedError() } diff --git a/backend/listen/imap_server/session_select.go b/backend/listen/imap_server/session_select.go index 5be9db9..ec03871 100644 --- a/backend/listen/imap_server/session_select.go +++ b/backend/listen/imap_server/session_select.go @@ -16,12 +16,14 @@ func (s *serverSession) Select(mailbox string, options *imap.SelectOptions) (*im } mailbox = strings.Trim(mailbox, `"`) + readOnly := options != nil && options.ReadOnly _, data, err := group.GetGroupStatus(s.ctx, mailbox, []string{"MESSAGES", "UNSEEN", "UIDNEXT", "UIDVALIDITY"}) if err != nil { log.WithContext(s.ctx).WithError(err).Error("query selected mailbox status failed") return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Select failed"} } s.currentMailbox = mailbox + s.readOnly = readOnly numMessages, err := imapUint32(data["MESSAGES"]) if err != nil { diff --git a/backend/listen/imap_server/session_store.go b/backend/listen/imap_server/session_store.go index bb5e684..2c9888d 100644 --- a/backend/listen/imap_server/session_store.go +++ b/backend/listen/imap_server/session_store.go @@ -18,6 +18,9 @@ func (s *serverSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, fla if s == nil || s.ctx == nil || s.ctx.UserID <= 0 || flags == nil { return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Store failed"} } + if s.readOnly { + return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "Mailbox is read-only"} + } var emailList []*response.EmailResponseData diff --git a/backend/services/group/group.go b/backend/services/group/group.go index 501c2e4..93c2da1 100644 --- a/backend/services/group/group.go +++ b/backend/services/group/group.go @@ -398,7 +398,11 @@ func MoveMailToGroup(ctx *context.Context, mailIDs []int, groupID int) error { rollbackGroupTransaction(session) return fmt.Errorf("load message ownership: %w", err) } - if len(relations) != len(uniqueIDs) { + ownedIDs := make(map[int]struct{}, len(relations)) + for _, relation := range relations { + ownedIDs[relation.EmailID] = struct{}{} + } + if len(ownedIDs) != len(uniqueIDs) { rollbackGroupTransaction(session) return errors2.New("one or more messages are not owned by the user") } @@ -429,6 +433,48 @@ func MoveMailToGroup(ctx *context.Context, mailIDs []int, groupID int) error { } return nil } + +// MoveRelationsToGroup 按当前用户的邮箱关系 ID 移动邮件,避免同一封邮件的多个关系互相影响。 +func MoveRelationsToGroup(ctx *context.Context, relationIDs []int, groupID int) error { + if groupID <= 0 { + return errors2.New("invalid message move request") + } + uniqueIDs, err := normalizeMoveMessageIDs(relationIDs) + if err != nil { + return err + } + session := db.Instance.NewSession() + defer session.Close() + if err := session.Begin(); err != nil { + return fmt.Errorf("begin relation move transaction: %w", err) + } + var target models.Group + found, err := session.Table("group").Where("id = ? and user_id = ?", groupID, ctx.UserID).Get(&target) + if err != nil || !found { + rollbackGroupTransaction(session) + if err != nil { + return fmt.Errorf("load move destination: %w", err) + } + return errors2.New("move destination not found") + } + var relations []models.UserEmail + if err := session.Table(&models.UserEmail{}).Where(builder.Eq{"id": uniqueIDs, "user_id": ctx.UserID}).And("email_id > 0").Find(&relations); err != nil { + rollbackGroupTransaction(session) + return fmt.Errorf("load relation ownership: %w", err) + } + if len(relations) != len(uniqueIDs) { + rollbackGroupTransaction(session) + return errors2.New("one or more messages are not owned by the user") + } + if _, err := session.Table(&models.UserEmail{}).Where(builder.Eq{"id": uniqueIDs, "user_id": ctx.UserID}).Cols("group_id").Update(map[string]interface{}{"group_id": groupID}); err != nil { + rollbackGroupTransaction(session) + return fmt.Errorf("move relations: %w", err) + } + if err := session.Commit(); err != nil { + return fmt.Errorf("commit relation move transaction: %w", err) + } + return nil +} func buildChildren(ctx *context.Context, parentId int, visited map[int]struct{}, depth int) ([]*GroupItem, error) { if depth >= maxGroupDepth { return nil, errors2.New("group hierarchy is too deep") @@ -569,14 +615,14 @@ func GetGroupStatus(ctx *context.Context, groupName string, params []string) (st var err error switch param { case "MESSAGES": - value, err = readGroupValue(db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and user_id=?", group.ID, ctx.UserID), "query mailbox message count failed") + value, err = readGroupValue(db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and user_id=? and email_id > 0", group.ID, ctx.UserID), "query mailbox message count failed") case "UIDNEXT": value, err = readGroupValue(db.Instance.Table("user_email").Select("id").Where("group_id=? and user_id=?", group.ID, ctx.UserID).OrderBy("id desc"), "query mailbox next UID failed") value++ case "UIDVALIDITY": value = group.ID case "UNSEEN": - value, err = readGroupValue(db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and user_id=? and is_read=0", group.ID, ctx.UserID), "query mailbox unseen count failed") + value, err = readGroupValue(db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and user_id=? and email_id > 0 and is_read=0", group.ID, ctx.UserID), "query mailbox unseen count failed") default: continue } @@ -629,21 +675,10 @@ func readGroupValue(querySession *xorm.Session, operation string) (int, error) { } func getNextUID(ctx *context.Context, groupName string) (int, error) { - var querySession *xorm.Session - switch groupName { - case "INBOX": - querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 0", ctx.UserID).OrderBy("id desc") - case "Sent Messages": - querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 1", ctx.UserID).OrderBy("id desc") - case "Drafts": - querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 4", ctx.UserID).OrderBy("id desc") - case "Deleted Messages": - querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 3", ctx.UserID).OrderBy("id desc") - case "Junk": - querySession = db.Instance.Table("user_email").Select("id").Where("user_id=? and group_id=0 and status = 5", ctx.UserID).OrderBy("id desc") - default: + if !IsDefaultBox(groupName) { return 0, errors2.New("invalid default mailbox") } + querySession := db.Instance.Table("user_email").Select("id").Where("user_id=? and email_id > 0", ctx.UserID).OrderBy("id desc") value, err := readGroupValue(querySession, "query next mailbox UID failed") if err != nil { return 0, err @@ -656,32 +691,32 @@ func getGroupNum(ctx *context.Context, groupName string, mustUnread bool) (int, switch groupName { case "INBOX": if mustUnread { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=0 and is_read=0", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=0 and email_id > 0 and is_read=0", ctx.UserID) } else { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=0", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=0 and email_id > 0", ctx.UserID) } case "Sent Messages": if mustUnread { return 0, nil } - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=1", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=1 and email_id > 0", ctx.UserID) case "Drafts": if mustUnread { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=4 and is_read=0", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=4 and email_id > 0 and is_read=0", ctx.UserID) } else { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=4", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=4 and email_id > 0", ctx.UserID) } case "Deleted Messages": if mustUnread { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=3 and is_read=0", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=3 and email_id > 0 and is_read=0", ctx.UserID) } else { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=3", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=3 and email_id > 0", ctx.UserID) } case "Junk": if mustUnread { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=5 and is_read=0", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=5 and email_id > 0 and is_read=0", ctx.UserID) } else { - querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=5", ctx.UserID) + querySession = db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and group_id=0 and status=5 and email_id > 0", ctx.UserID) } default: return 0, errors2.New("invalid default mailbox") @@ -779,3 +814,48 @@ func Move2DefaultBox(ctx *context.Context, mailIDs []int, groupName string) erro } return nil } + +// MoveRelationsToDefaultBox 按当前用户的邮箱关系 ID 移动邮件到系统邮箱。 +func MoveRelationsToDefaultBox(ctx *context.Context, relationIDs []int, groupName string) error { + var status int8 + switch groupName { + case "INBOX": + status = consts.EmailTypeReceive + case "Sent Messages": + status = consts.EmailStatusSent + case "Drafts": + status = consts.EmailStatusDrafts + case "Deleted Messages": + status = consts.EmailStatusDel + case "Junk": + status = consts.EmailStatusJunk + default: + return errors2.New("invalid default mailbox") + } + uniqueIDs, err := normalizeMoveMessageIDs(relationIDs) + if err != nil { + return err + } + session := db.Instance.NewSession() + defer session.Close() + if err := session.Begin(); err != nil { + return fmt.Errorf("begin relation default mailbox move transaction: %w", err) + } + var relations []models.UserEmail + if err := session.Table(&models.UserEmail{}).Where(builder.Eq{"id": uniqueIDs, "user_id": ctx.UserID}).And("email_id > 0").Find(&relations); err != nil { + rollbackGroupTransaction(session) + return fmt.Errorf("load relation default mailbox ownership: %w", err) + } + if len(relations) != len(uniqueIDs) { + rollbackGroupTransaction(session) + return errors2.New("one or more messages are not owned by the user") + } + if _, err := session.Table(&models.UserEmail{}).Where(builder.Eq{"id": uniqueIDs, "user_id": ctx.UserID}).Cols("status", "group_id").Update(map[string]interface{}{"status": status, "group_id": 0}); err != nil { + rollbackGroupTransaction(session) + return fmt.Errorf("move relations to default mailbox: %w", err) + } + if err := session.Commit(); err != nil { + return fmt.Errorf("commit relation default mailbox move transaction: %w", err) + } + return nil +} diff --git a/backend/services/list/imap_search.go b/backend/services/list/imap_search.go index 703575e..de736ee 100644 --- a/backend/services/list/imap_search.go +++ b/backend/services/list/imap_search.go @@ -170,37 +170,32 @@ func validIMAPNumber(value int) (uint32, bool) { // filterByUIDSets filters the list by UID sets func filterByUIDSets(list []*response.UserEmailUIDData, uidSets []imap.UIDSet) []*response.UserEmailUIDData { - var result []*response.UserEmailUIDData - for _, item := range list { - number, ok := validIMAPNumber(item.ID) - if !ok { - continue - } - uid := imap.UID(number) - for _, uidSet := range uidSets { - if uidSet.Contains(uid) { - result = append(result, item) - break + result := list + for _, uidSet := range uidSets { + filtered := make([]*response.UserEmailUIDData, 0, len(result)) + for _, item := range result { + number, ok := validIMAPNumber(item.ID) + if ok && uidSet.Contains(imap.UID(number)) { + filtered = append(filtered, item) } } + result = filtered } return result } // filterBySeqNumSets filters the list by sequence number sets func filterBySeqNumSets(list []*response.UserEmailUIDData, seqSets []imap.SeqSet) []*response.UserEmailUIDData { - var result []*response.UserEmailUIDData - for _, item := range list { - number, ok := validIMAPNumber(item.SerialNumber) - if !ok { - continue - } - for _, seqSet := range seqSets { - if seqSet.Contains(number) { - result = append(result, item) - break + result := list + for _, seqSet := range seqSets { + filtered := make([]*response.UserEmailUIDData, 0, len(result)) + for _, item := range result { + number, ok := validIMAPNumber(item.SerialNumber) + if ok && seqSet.Contains(number) { + filtered = append(filtered, item) } } + result = filtered } return result } @@ -242,7 +237,7 @@ func hasFlag(item *response.UserEmailUIDData, flag imap.Flag) bool { case imap.FlagSeen: return item.IsRead == 1 case imap.FlagDeleted: - return item.Status == 3 + return item.IsDeleted == 1 case imap.FlagDraft: return item.Status == 4 case imap.FlagJunk: @@ -468,16 +463,14 @@ func applyOrCriteria(ctx *context.Context, groupName string, list []*response.Us return list, nil } - // Build a set of current UIDs for intersection + // 多个 OR 子句仍属于同一个 SEARCH,子句之间取交集。 currentUIDs := make(map[int]bool) for _, item := range list { currentUIDs[item.ID] = true } - // For each OR pair, find items matching either condition - resultUIDs := make(map[int]bool) - for _, pair := range orCriteria { + pairUIDs := make(map[int]bool) // Get matches for first condition matches1, err := SearchEmails(ctx, groupName, &pair[0]) if err != nil { @@ -485,7 +478,7 @@ func applyOrCriteria(ctx *context.Context, groupName string, list []*response.Us } for _, item := range matches1 { if currentUIDs[item.ID] { - resultUIDs[item.ID] = true + pairUIDs[item.ID] = true } } @@ -496,7 +489,12 @@ func applyOrCriteria(ctx *context.Context, groupName string, list []*response.Us } for _, item := range matches2 { if currentUIDs[item.ID] { - resultUIDs[item.ID] = true + pairUIDs[item.ID] = true + } + } + for uid := range currentUIDs { + if !pairUIDs[uid] { + delete(currentUIDs, uid) } } } @@ -504,7 +502,7 @@ func applyOrCriteria(ctx *context.Context, groupName string, list []*response.Us // Build result from original list preserving order var result []*response.UserEmailUIDData for _, item := range list { - if resultUIDs[item.ID] { + if currentUIDs[item.ID] { result = append(result, item) } } diff --git a/backend/services/list/list.go b/backend/services/list/list.go index 2409db8..14674dd 100644 --- a/backend/services/list/list.go +++ b/backend/services/list/list.go @@ -133,7 +133,7 @@ func buildIMAPMailboxRelationQuery(ctx *context.Context, groupName string) (stri if ctx == nil || ctx.UserID <= 0 { return "", nil, fmt.Errorf("invalid mailbox user") } - inner := "SELECT id,email_id,is_read,is_deleted,ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM user_email WHERE user_id = ?" + inner := "SELECT id,email_id,is_read,is_deleted,status,ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM user_email WHERE user_id = ? AND email_id > 0" params := []any{ctx.UserID} switch groupName { case "INBOX": @@ -224,6 +224,23 @@ func getEmailListByUidList(ctx *context.Context, groupName string, req ImapListR return nil, err } sql := "SELECT * FROM (" + inner + ") mailbox WHERE " + if req.Star == 0 && req.End == 0 && len(req.UidList) == 0 { + maxUID, err := GetMaxUIDByMailbox(ctx, groupName) + if err != nil { + return nil, err + } + if maxUID == 0 { + return []*response.EmailResponseData{}, nil + } + sql += "id = ?" + params = append(params, maxUID) + sql += " ORDER BY id" + var relations []*response.UserEmailUIDData + if err := db.Instance.SQL(sql, params...).Find(&relations); err != nil { + return nil, fmt.Errorf("query mailbox wildcard UID: %w", err) + } + return hydrateIMAPEmailResponses(relations) + } if req.Star > 0 { sql += "id >= ?" params = append(params, req.Star) @@ -248,7 +265,7 @@ func getEmailListByUidList(ctx *context.Context, groupName string, req ImapListR } func GetEmailListByGroup(ctx *context.Context, groupName string, req ImapListReq, uid bool) ([]*response.EmailResponseData, error) { - if len(req.UidList) == 0 && req.Star == 0 && req.End == 0 { + if len(req.UidList) == 0 && req.Star == 0 && req.End == 0 && !uid { return nil, nil } if uid { @@ -315,11 +332,12 @@ func hydrateIMAPEmailResponses(relations []*response.UserEmailUIDData) ([]*respo if message == nil { continue } - message.IsRead = relation.IsRead - message.IsDeleted = relation.IsDeleted - message.SerialNumber = relation.SerialNumber - message.UeId = relation.ID - result = append(result, message) + copyMessage := *message + copyMessage.IsRead = relation.IsRead + copyMessage.IsDeleted = relation.IsDeleted + copyMessage.SerialNumber = relation.SerialNumber + copyMessage.UeId = relation.ID + result = append(result, ©Message) } return result, nil } diff --git a/test/review/dbmigration_findings_test.go b/test/review/dbmigration_findings_test.go index 1516194..0fd9757 100644 --- a/test/review/dbmigration_findings_test.go +++ b/test/review/dbmigration_findings_test.go @@ -64,7 +64,11 @@ func TestAuditRequestDrainMustRespectCancellation(t *testing.T) { defer cancel() done := make(chan struct{}) go func() { - release := service.drainRequests(ctx) + release, err := service.drainRequests(ctx) + if err != nil { + close(done) + return + } release() close(done) }()