From cf4f5f258c63dffd5ebd924ef14d61a28a42c0cb Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 5 Sep 2026 08:20:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=91=E4=BF=A1=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=B8=85=E6=B4=97=E4=B8=8E=E4=BA=8B=E5=8A=A1=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/controllers/email/send.go | 94 ++++++++++++--------- server/controllers/email/send_test.go | 108 +++++++++++++++++++++++++ server/dto/parsemail/email.go | 18 ++--- server/dto/parsemail/email_xss_test.go | 16 ++-- 4 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 server/controllers/email/send_test.go diff --git a/server/controllers/email/send.go b/server/controllers/email/send.go index 0d57dc9..129d6bc 100644 --- a/server/controllers/email/send.go +++ b/server/controllers/email/send.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/consts" "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto/parsemail" "git.pcux.cn/xuans/Smail/dto/response" @@ -24,6 +25,7 @@ import ( "strings" "time" "unicode" + "xorm.io/xorm" ) const ( @@ -159,9 +161,9 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } } - e.Text = []byte(reqData.Text) - e.HTML = []byte(reqData.HTML) - e.Subject = reqData.Subject + e.Text = []byte(parsemail.SanitizeText(reqData.Text)) + e.HTML = []byte(parsemail.SanitizeHTML(reqData.HTML)) + e.Subject = parsemail.SanitizeText(reqData.Subject) totalAttachmentSize := 0 for _, att := range reqData.Attachments { contentType, content, err := decodeAttachment(att.Data) @@ -222,8 +224,9 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } + now := time.Now() modelEmail := models.Email{ - Type: 1, + Type: consts.EmailTypeSend, Subject: e.Subject, ReplyTo: storedFields.ReplyTo, FromName: e.From.Name, @@ -238,18 +241,38 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { SPFCheck: 1, DKIMCheck: 1, SendUserID: ctx.UserID, - SendDate: time.Now(), - CronSendTime: time.Now(), - Status: 1, - CreateTime: time.Now(), + SendDate: now, + CronSendTime: now, + Status: consts.EmailStatusSent, + CreateTime: now, MsgID: msgID, } - _, err = db.Instance.Insert(&modelEmail) + err = db.Transaction(db.Instance, func(session *xorm.Session) error { + candidate := modelEmail + candidate.Id = 0 + if _, insertErr := session.Insert(&candidate); insertErr != nil { + return insertErr + } + if candidate.Id <= 0 { + return fmt.Errorf("insert outgoing email returned no identifier") + } + relation := models.UserEmail{ + UserID: ctx.UserID, + EmailID: candidate.Id, + Status: consts.EmailStatusSent, + IsRead: 1, + } + if _, insertErr := session.Insert(&relation); insertErr != nil { + return insertErr + } + modelEmail = candidate + return nil + }) if err != nil || modelEmail.Id <= 0 { if err != nil { - log.WithContext(ctx).WithError(err).Error("insert outgoing email failed") + log.WithContext(ctx).WithError(err).Error("insert outgoing email transaction failed") } else { log.WithContext(ctx).Error("insert outgoing email returned no identifier") } @@ -280,36 +303,29 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if err != nil { errMsg = err.Error() - _, err := db.Instance.Exec(db.WithContext(ctx, "update email set status =2 ,error=? where id = ? "), errMsg, modelEmail.Id) - if err != nil { - log.WithContext(ctx).Errorf("sql Error :%+v", err) - } - - ue := models.UserEmail{ - UserID: ctx.UserID, - EmailID: modelEmail.Id, - Status: 2, - IsRead: 1, - } - if _, insertErr := db.Instance.Insert(&ue); insertErr != nil { - log.WithContext(ctx).WithError(insertErr).Error("insert failed outgoing email relation failed") - } - + _ = db.Transaction(db.Instance, func(session *xorm.Session) error { + if _, updateErr := session.Exec(db.WithContext(ctx, "update email set status = ?, error = ? where id = ?"), consts.EmailStatusFail, errMsg, modelEmail.Id); updateErr != nil { + log.WithContext(ctx).Errorf("update email status failed: %+v", updateErr) + return updateErr + } + if _, updateErr := session.Exec(db.WithContext(ctx, "update user_email set status = ? where email_id = ? and user_id = ?"), consts.EmailStatusFail, modelEmail.Id, ctx.UserID); updateErr != nil { + log.WithContext(ctx).Errorf("update user_email status failed: %+v", updateErr) + return updateErr + } + return nil + }) } else { - _, err := db.Instance.Exec(db.WithContext(ctx, "update email set status =1 where id = ? "), modelEmail.Id) - if err != nil { - log.WithContext(ctx).Errorf("sql Error :%+v", err) - } - - ue := models.UserEmail{ - UserID: ctx.UserID, - EmailID: modelEmail.Id, - Status: 1, - IsRead: 1, - } - if _, insertErr := db.Instance.Insert(&ue); insertErr != nil { - log.WithContext(ctx).WithError(insertErr).Error("insert sent email relation failed") - } + _ = db.Transaction(db.Instance, func(session *xorm.Session) error { + if _, updateErr := session.Exec(db.WithContext(ctx, "update email set status = ?, error = '' where id = ?"), consts.EmailStatusSent, modelEmail.Id); updateErr != nil { + log.WithContext(ctx).Errorf("update email status failed: %+v", updateErr) + return updateErr + } + if _, updateErr := session.Exec(db.WithContext(ctx, "update user_email set status = ? where email_id = ? and user_id = ?"), consts.EmailStatusSent, modelEmail.Id, ctx.UserID); updateErr != nil { + log.WithContext(ctx).Errorf("update user_email status failed: %+v", updateErr) + return updateErr + } + return nil + }) } }, nil) diff --git a/server/controllers/email/send_test.go b/server/controllers/email/send_test.go new file mode 100644 index 0000000..ce1d459 --- /dev/null +++ b/server/controllers/email/send_test.go @@ -0,0 +1,108 @@ +package email + +import ( + "testing" + "time" + + "git.pcux.cn/xuans/Smail/consts" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/context" + "xorm.io/xorm" +) + +func TestSendTransactionalPersistenceConsistency(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatalf("create test engine: %v", err) + } + engine.SetMaxOpenConns(1) + if err := engine.Sync2(&models.Email{}, &models.UserEmail{}); err != nil { + t.Fatalf("sync test database: %v", err) + } + defer engine.Close() + + ctx := &context.Context{ + UserID: 1, + UserAccount: "testuser", + UserName: "Test User", + } + + now := time.Now() + modelEmail := models.Email{ + Type: consts.EmailTypeSend, + Subject: "Test Email", + FromName: "Test User", + FromAddress: "testuser@example.com", + SendUserID: ctx.UserID, + SendDate: now, + CronSendTime: now, + Status: consts.EmailStatusSent, + CreateTime: now, + MsgID: "msg-123@example.com", + } + + err = db.Transaction(engine, func(session *xorm.Session) error { + candidate := modelEmail + candidate.Id = 0 + if _, insertErr := session.Insert(&candidate); insertErr != nil { + return insertErr + } + relation := models.UserEmail{ + UserID: ctx.UserID, + EmailID: candidate.Id, + Status: consts.EmailStatusSent, + IsRead: 1, + } + if _, insertErr := session.Insert(&relation); insertErr != nil { + return insertErr + } + modelEmail = candidate + return nil + }) + if err != nil { + t.Fatalf("insert email transaction failed: %v", err) + } + + var email models.Email + has, err := engine.ID(modelEmail.Id).Get(&email) + if err != nil || !has { + t.Fatalf("email not found in database: %v", err) + } + if email.Status != consts.EmailStatusSent || email.Type != consts.EmailTypeSend { + t.Fatalf("unexpected email status/type: status=%d, type=%d", email.Status, email.Type) + } + + var userEmail models.UserEmail + has, err = engine.Where("email_id = ? AND user_id = ?", modelEmail.Id, ctx.UserID).Get(&userEmail) + if err != nil || !has { + t.Fatalf("user_email not found: %v", err) + } + if userEmail.Status != consts.EmailStatusSent { + t.Fatalf("unexpected user_email status: %d", userEmail.Status) + } +} + +func TestSafeAttachmentName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "normal", input: "test.pdf", expected: "test.pdf"}, + {name: "path traversal", input: "../../etc/passwd", expected: ".._.._etc_passwd"}, + {name: "windows path", input: "C:\\Users\\file.txt", expected: "C:_Users_file.txt"}, + {name: "control chars", input: "file\r\nname\x00.png", expected: "file__name_.png"}, + {name: "empty", input: "", expected: "attachment"}, + {name: "dots", input: "..", expected: "attachment"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := safeAttachmentName(tt.input) + if result != tt.expected { + t.Errorf("safeAttachmentName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index 6928878..799e84b 100644 --- a/server/dto/parsemail/email.go +++ b/server/dto/parsemail/email.go @@ -195,7 +195,7 @@ func init() { relaxedPolicy.SkipElementsContent("script", "object", "embed", "iframe", "frame", "frameset") } -func sanitizeHTML(htmlContent string) string { +func SanitizeHTML(htmlContent string) string { if htmlContent == "" { return "" } @@ -269,7 +269,7 @@ func sanitizeHTMLNode(node *xhtml.Node) bool { } // Sanitize Text -func sanitizeText(text string) string { +func SanitizeText(text string) string { return strictPolicy.Sanitize(text) } @@ -461,8 +461,8 @@ func formatContent(entity *message.Entity, ret *Email) error { fileName := getFileName(entity, p) ret.Attachments = append(ret.Attachments, &Attachment{ - Filename: sanitizeText(fileName), - ContentType: sanitizeText(strings.TrimSpace(contentType)), + Filename: SanitizeText(fileName), + ContentType: SanitizeText(strings.TrimSpace(contentType)), Content: body, ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"), }) @@ -481,8 +481,8 @@ func formatContent(entity *message.Entity, ret *Email) error { fileName := getFileName(entity, p) ret.Attachments = append(ret.Attachments, &Attachment{ - Filename: sanitizeText(fileName), - ContentType: sanitizeText(strings.TrimSpace(contentType)), + Filename: SanitizeText(fileName), + ContentType: SanitizeText(strings.TrimSpace(contentType)), Content: body, ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"), }) @@ -490,7 +490,7 @@ func formatContent(entity *message.Entity, ret *Email) error { return nil } - ret.HTML = []byte(sanitizeHTML(string(body))) + ret.HTML = []byte(SanitizeHTML(string(body))) case "multipart/related": default: c, err := io.ReadAll(entity.Body) @@ -500,8 +500,8 @@ func formatContent(entity *message.Entity, ret *Email) error { fileName := getFileName(entity, p) ret.Attachments = append(ret.Attachments, &Attachment{ - Filename: sanitizeText(fileName), - ContentType: sanitizeText(strings.TrimSpace(contentType)), + Filename: SanitizeText(fileName), + ContentType: SanitizeText(strings.TrimSpace(contentType)), Content: c, ContentID: strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"), }) diff --git a/server/dto/parsemail/email_xss_test.go b/server/dto/parsemail/email_xss_test.go index 70f9fe6..c8b5c72 100644 --- a/server/dto/parsemail/email_xss_test.go +++ b/server/dto/parsemail/email_xss_test.go @@ -85,9 +85,9 @@ func TestSanitizeHTML(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := sanitizeHTML(tc.input) + result := SanitizeHTML(tc.input) if result != tc.expected { - t.Errorf("sanitizeHTML(%q) = %q, want %q", tc.input, result, tc.expected) + t.Errorf("SanitizeHTML(%q) = %q, want %q", tc.input, result, tc.expected) } }) } @@ -143,9 +143,9 @@ func TestSanitizeText(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := sanitizeText(tc.input) + result := SanitizeText(tc.input) if result != tc.expected { - t.Errorf("sanitizeText(%q) = %q, want %q", tc.input, result, tc.expected) + t.Errorf("SanitizeText(%q) = %q, want %q", tc.input, result, tc.expected) } }) } @@ -328,13 +328,13 @@ func TestAttachment_XSS(t *testing.T) { // Test that sanitizeText function works correctly on attachment data dangerousFilename := `malicious.txt` - sanitizedFilename := sanitizeText(dangerousFilename) + sanitizedFilename := SanitizeText(dangerousFilename) if strings.Contains(sanitizedFilename, "` - sanitizedContentType := sanitizeText(dangerousContentType) + sanitizedContentType := SanitizeText(dangerousContentType) if strings.Contains(sanitizedContentType, "