完成IMAP关系与搜索边界修复检查点

This commit is contained in:
smail 2026-09-06 06:58:43 +08:00
parent b5ff928f44
commit c4777dae85
10 changed files with 182 additions and 74 deletions

View File

@ -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
}

View File

@ -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"}
}

View File

@ -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()

View File

@ -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()
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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, &copyMessage)
}
return result, nil
}

View File

@ -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)
}()