From 3ae8ecb11cf40b994d21d637fbbe3f2d688e3e01 Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 17 Sep 2026 00:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E4=BA=8B=E5=8A=A1=E5=8E=9F=E5=AD=90=E6=80=A7=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=9E=E6=BB=9A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/email/read.go | 10 +-- backend/services/detail/batch_read_test.go | 99 ++++++++++++++++++++++ backend/services/detail/detail.go | 55 ++++++++++++ 3 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 backend/services/detail/batch_read_test.go diff --git a/backend/controllers/email/read.go b/backend/controllers/email/read.go index ccced46..681b4d1 100644 --- a/backend/controllers/email/read.go +++ b/backend/controllers/email/read.go @@ -25,12 +25,10 @@ func MarkRead(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } - for _, id := range reqData.IDs { - if _, err := detail.GetEmailDetail(ctx, id, true); err != nil { - log.WithContext(ctx).WithError(err).Error("mark email read failed") - response.NewErrorResponse(response.ServerError, "更新邮件失败", "").FPrint(w) - return - } + if err := detail.MarkEmailsRead(ctx, reqData.IDs); err != nil { + log.WithContext(ctx).WithError(err).Error("mark email read failed") + response.NewErrorResponse(response.ServerError, "更新邮件失败", "").FPrint(w) + return } response.NewSuccessResponse("success").FPrint(w) diff --git a/backend/services/detail/batch_read_test.go b/backend/services/detail/batch_read_test.go new file mode 100644 index 0000000..e2b2a8c --- /dev/null +++ b/backend/services/detail/batch_read_test.go @@ -0,0 +1,99 @@ +package detail + +import ( + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/context" +) + +func initBatchReadTestDB(t *testing.T) { + t.Helper() + oldConfig := config.Instance + oldDB := db.Instance + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "batch-read.db")} + if err := db.Init(""); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if db.Instance != nil { + _ = db.Instance.Close() + } + db.Instance = oldDB + config.Instance = oldConfig + }) +} + +func TestMarkEmailsReadRejectsPartiallyOwnedBatch(t *testing.T) { + initBatchReadTestDB(t) + if _, err := db.Instance.Insert(&models.UserEmail{UserID: 1, EmailID: 101}, &models.UserEmail{UserID: 2, EmailID: 202}); err != nil { + t.Fatal(err) + } + + err := MarkEmailsRead(&context.Context{UserID: 1}, []int{101, 202}) + if err == nil { + t.Fatal("partially owned batch was accepted") + } + + var relation models.UserEmail + if has, loadErr := db.Instance.Where("user_id = ? and email_id = ?", 1, 101).Get(&relation); loadErr != nil || !has { + t.Fatalf("load owned relation: has=%v err=%v", has, loadErr) + } + if relation.IsRead != 0 { + t.Fatalf("owned relation changed after rejected batch: is_read=%d", relation.IsRead) + } +} + +func TestMarkEmailsReadRollsBackWhenUpdateFails(t *testing.T) { + initBatchReadTestDB(t) + if _, err := db.Instance.Insert(&models.UserEmail{UserID: 1, EmailID: 301}, &models.UserEmail{UserID: 1, EmailID: 302}); err != nil { + t.Fatal(err) + } + trigger := "CREATE TRIGGER fail_batch_read BEFORE UPDATE OF is_read ON user_email " + + "WHEN NEW.email_id = 302 BEGIN SELECT RAISE(ABORT, 'forced batch read failure'); END" + if _, err := db.Instance.Exec(trigger); err != nil { + t.Fatal(err) + } + + err := MarkEmailsRead(&context.Context{UserID: 1}, []int{301, 302}) + if err == nil { + t.Fatal("batch read update unexpectedly succeeded") + } + + var relations []models.UserEmail + if err := db.Instance.Where("user_id = ?", 1).OrderBy("email_id").Find(&relations); err != nil { + t.Fatal(err) + } + if len(relations) != 2 || relations[0].IsRead != 0 || relations[1].IsRead != 0 { + t.Fatalf("batch read was partially committed: %+v", relations) + } +} + +func TestMarkEmailsReadUpdatesOwnedBatchOnce(t *testing.T) { + initBatchReadTestDB(t) + if _, err := db.Instance.Insert(&models.UserEmail{UserID: 1, EmailID: 401}, &models.UserEmail{UserID: 1, EmailID: 402, IsRead: 1}); err != nil { + t.Fatal(err) + } + + if err := MarkEmailsRead(&context.Context{UserID: 1}, []int{401, 401, 402}); err != nil { + t.Fatal(err) + } + + var unread int64 + if unread, _ = db.Instance.Where("user_id = ? and is_read = 0", 1).Count(&models.UserEmail{}); unread != 0 { + t.Fatalf("unread relation count = %d", unread) + } +} + +func TestMarkEmailsReadRejectsInvalidID(t *testing.T) { + initBatchReadTestDB(t) + if err := MarkEmailsRead(&context.Context{UserID: 1}, []int{0}); err == nil { + t.Fatal("invalid email id was accepted") + } + if err := MarkEmailsRead(&context.Context{UserID: 1}, nil); err == nil { + t.Fatal("empty email id list was accepted") + } +} diff --git a/backend/services/detail/detail.go b/backend/services/detail/detail.go index 519fac3..3e8e4c4 100644 --- a/backend/services/detail/detail.go +++ b/backend/services/detail/detail.go @@ -13,6 +13,7 @@ import ( "git.pcux.cn/xuans/Smail/utils/errors" "git.pcux.cn/xuans/Smail/utils/query" "strings" + "xorm.io/xorm" ) import . "xorm.io/builder" @@ -100,6 +101,60 @@ func MakeRead(ctx *context.Context, emailID int, read bool) error { } return nil } + +func MarkEmailsRead(ctx *context.Context, emailIDs []int) error { + if ctx == nil || ctx.UserID <= 0 || db.Instance == nil { + return errors.New("Not authorized") + } + + uniqueIDs := make([]int, 0, len(emailIDs)) + seenIDs := make(map[int]struct{}, len(emailIDs)) + for _, emailID := range emailIDs { + if emailID <= 0 { + return errors.New("invalid email id") + } + if _, exists := seenIDs[emailID]; exists { + continue + } + seenIDs[emailID] = struct{}{} + uniqueIDs = append(uniqueIDs, emailID) + } + if len(uniqueIDs) == 0 { + return errors.New("empty email id list") + } + + placeholders, idArgs, ok := query.IntList(uniqueIDs, 10000) + if !ok { + return errors.New("invalid email id list") + } + + return db.Transaction(db.Instance, func(session *xorm.Session) error { + var relations []models.UserEmail + selectArgs := append([]any{ctx.UserID}, idArgs...) + selectSQL := fmt.Sprintf("SELECT id,email_id,is_read FROM user_email WHERE user_id = ? AND email_id IN (%s)", placeholders) + if err := session.SQL(selectSQL, selectArgs...).Find(&relations); err != nil { + return fmt.Errorf("load email ownership for read update: %w", err) + } + + ownedIDs := make(map[int]struct{}, len(relations)) + for _, relation := range relations { + ownedIDs[relation.EmailID] = struct{}{} + } + for _, emailID := range uniqueIDs { + if _, owned := ownedIDs[emailID]; !owned { + return fmt.Errorf("email %d is not owned by current user", emailID) + } + } + + updateArgs := append([]any{ctx.UserID}, idArgs...) + updateSQL := fmt.Sprintf("user_id = ? AND email_id IN (%s) AND is_read <> 1", placeholders) + if _, err := session.Table("user_email").Where(updateSQL, updateArgs...).Cols("is_read").Update(map[string]any{"is_read": 1}); err != nil { + return fmt.Errorf("update email read state: %w", err) + } + return nil + }) +} + func FindUE(ctx *context.Context, groupName string, req list.ImapListReq, uid bool) ([]models.UserEmail, error) { var ue []models.UserEmail if uid {