From 49ab24b064ffb83b10274dfa228988cb410859bf Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 16 Sep 2026 21:02:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=82=E6=AD=A5=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=8A=95=E9=80=92=E7=8A=B6=E6=80=81=E4=B8=8E587?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E5=8A=A0=E5=AF=86=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/email/list.go | 26 ++++---- backend/controllers/email/send.go | 51 ++++++++------- backend/controllers/email/send_test.go | 86 +++++++++++++++++++------- backend/utils/send/send.go | 4 +- frontend/src/i18n/i18n.js | 2 +- frontend/src/views/List/List.vue | 8 ++- 6 files changed, 116 insertions(+), 61 deletions(-) diff --git a/backend/controllers/email/list.go b/backend/controllers/email/list.go index 1585303..0ddc288 100644 --- a/backend/controllers/email/list.go +++ b/backend/controllers/email/list.go @@ -7,10 +7,10 @@ import ( "net/http" "strings" + "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto" "git.pcux.cn/xuans/Smail/dto/parsemail" "git.pcux.cn/xuans/Smail/dto/response" - "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/list" "git.pcux.cn/xuans/Smail/utils/context" @@ -31,17 +31,18 @@ type emailListResponse struct { } type emilItem struct { - ID int `json:"id"` - Title string `json:"title"` - Desc string `json:"desc"` - Datetime string `json:"datetime"` - IsRead bool `json:"is_read"` - Sender User `json:"sender"` - To []User `json:"to"` - Dangerous bool `json:"dangerous"` - IsTrusted bool `json:"is_trusted"` - IsBlacklisted bool `json:"is_blacklisted"` - Error string `json:"error"` + ID int `json:"id"` + Title string `json:"title"` + Desc string `json:"desc"` + Datetime string `json:"datetime"` + IsRead bool `json:"is_read"` + Sender User `json:"sender"` + To []User `json:"to"` + Dangerous bool `json:"dangerous"` + IsTrusted bool `json:"is_trusted"` + IsBlacklisted bool `json:"is_blacklisted"` + Error string `json:"error"` + Status int8 `json:"status"` } type User struct { @@ -138,6 +139,7 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { IsTrusted: isTrusted, IsBlacklisted: isBlacklisted, Error: email.Error.String, + Status: email.Status, }) } diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go index 206c58f..822bde0 100644 --- a/backend/controllers/email/send.go +++ b/backend/controllers/email/send.go @@ -245,32 +245,12 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { SendUserID: ctx.UserID, SendDate: now, CronSendTime: now, - Status: consts.EmailStatusSent, + Status: consts.EmailStatusDeliveryPending, CreateTime: now, MsgID: msgID, } - 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 - }) + err = persistOutgoingEmail(db.Instance, ctx.UserID, &modelEmail) if err != nil || modelEmail.Id <= 0 { if err != nil { @@ -282,7 +262,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } - // ?????????????????? + // 邮件持久化成功后再记录联系人。 var contactList []models.UserContact for _, recipient := range append(append(e.To, e.Cc...), e.Bcc...) { if recipient != nil && recipient.EmailAddress != "" { @@ -347,6 +327,31 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { response.NewSuccessResponse(i18n.GetText(ctx.Lang, "succ")).FPrint(w) } +func persistOutgoingEmail(engine *xorm.Engine, userID int, email *models.Email) error { + return db.Transaction(engine, func(session *xorm.Session) error { + candidate := *email + candidate.Id = 0 + candidate.Status = consts.EmailStatusDeliveryPending + if _, err := session.Insert(&candidate); err != nil { + return err + } + if candidate.Id <= 0 { + return fmt.Errorf("insert outgoing email returned no identifier") + } + relation := models.UserEmail{ + UserID: userID, + EmailID: candidate.Id, + Status: consts.EmailStatusDeliveryPending, + IsRead: 1, + } + if _, err := session.Insert(&relation); err != nil { + return err + } + *email = candidate + return nil + }) +} + func decodeAttachment(data string) (string, []byte, error) { if !strings.HasPrefix(data, "data:") { return "", nil, fmt.Errorf("invalid data url") diff --git a/backend/controllers/email/send_test.go b/backend/controllers/email/send_test.go index 04001b7..f0e6fd3 100644 --- a/backend/controllers/email/send_test.go +++ b/backend/controllers/email/send_test.go @@ -1,6 +1,9 @@ package email import ( + "encoding/json" + "net/http/httptest" + "strings" "testing" "time" @@ -37,29 +40,12 @@ func TestSendTransactionalPersistenceConsistency(t *testing.T) { SendUserID: ctx.UserID, SendDate: now, CronSendTime: now, - Status: consts.EmailStatusSent, + Status: consts.EmailStatusDeliveryPending, 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 - }) + err = persistOutgoingEmail(engine, ctx.UserID, &modelEmail) if err != nil { t.Fatalf("insert email transaction failed: %v", err) } @@ -69,7 +55,7 @@ func TestSendTransactionalPersistenceConsistency(t *testing.T) { if err != nil || !has { t.Fatalf("email not found in database: %v", err) } - if email.Status != consts.EmailStatusSent || email.Type != consts.EmailTypeSend { + if email.Status != consts.EmailStatusDeliveryPending || email.Type != consts.EmailTypeSend { t.Fatalf("unexpected email status/type: status=%d, type=%d", email.Status, email.Type) } @@ -78,7 +64,7 @@ func TestSendTransactionalPersistenceConsistency(t *testing.T) { if err != nil || !has { t.Fatalf("user_email not found: %v", err) } - if userEmail.Status != consts.EmailStatusSent { + if userEmail.Status != consts.EmailStatusDeliveryPending { t.Fatalf("unexpected user_email status: %d", userEmail.Status) } } @@ -106,3 +92,61 @@ func TestSafeAttachmentName(t *testing.T) { }) } } + +func TestPersistOutgoingEmailRollsBackRelationFailure(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer engine.Close() + engine.SetMaxOpenConns(1) + if err := engine.Sync2(&models.Email{}); err != nil { + t.Fatal(err) + } + email := models.Email{Subject: "pending", Status: consts.EmailStatusSent} + if err := persistOutgoingEmail(engine, 1, &email); err == nil { + t.Fatal("missing relation table did not fail") + } + count, err := engine.Count(&models.Email{}) + if err != nil || count != 0 || email.Id != 0 { + t.Fatalf("rollback failed: count=%d id=%d error=%v", count, email.Id, err) + } +} + +func TestOutboxExposesDeliveryStatus(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer engine.Close() + engine.SetMaxOpenConns(1) + if err := engine.Sync2(&models.Email{}, &models.UserEmail{}, &models.UserTrustedSender{}); err != nil { + t.Fatal(err) + } + previous := db.Instance + db.Instance = engine + defer func() { db.Instance = previous }() + ctx := &context.Context{UserID: 1} + email := models.Email{Subject: "pending", Type: consts.EmailTypeSend, Status: consts.EmailStatusSent} + if err := persistOutgoingEmail(engine, ctx.UserID, &email); err != nil { + t.Fatal(err) + } + for _, status := range []int8{consts.EmailStatusDeliveryPending, consts.EmailStatusSent, consts.EmailStatusFail} { + if _, err := engine.ID(email.Id).Cols("status").Update(&models.Email{Status: status}); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest("POST", "/api/v1/email/list", strings.NewReader(`{"tag":"{\"type\":1,\"status\":-1}"}`)) + writer := httptest.NewRecorder() + EmailList(ctx, writer, request) + var response struct { + ErrorNo int `json:"errorNo"` + Data emailListResponse `json:"data"` + } + if err := json.Unmarshal(writer.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.ErrorNo != 0 || len(response.Data.List) != 1 || response.Data.List[0].Status != status { + t.Fatalf("outbox status %d: %s", status, writer.Body.String()) + } + } +} diff --git a/backend/utils/send/send.go b/backend/utils/send/send.go index 651176d..58a08a2 100644 --- a/backend/utils/send/send.go +++ b/backend/utils/send/send.go @@ -283,8 +283,8 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai "object": domain.mxHost + ":25", }).Infof("SMTP 25 端口 STARTTLS 投递失败: %s", err.Error()) - // 再试用587投递 - err = smtp.SendMailWithTls(strings.TrimSuffix(domain.mxHost, "."), domain.mxHost+":587", nil, from, fromDomain, buildAddress(tos), data) + // 587 使用 STARTTLS,只有 465 使用隐式 TLS。 + err = smtp.SendMail(strings.TrimSuffix(domain.mxHost, "."), domain.mxHost+":587", nil, from, fromDomain, buildAddress(tos), data) if err == nil { accepted.Add(int64(len(tos))) return diff --git a/frontend/src/i18n/i18n.js b/frontend/src/i18n/i18n.js index 152ff0c..045de8c 100644 --- a/frontend/src/i18n/i18n.js +++ b/frontend/src/i18n/i18n.js @@ -37,7 +37,7 @@ const lang = { "only_prefix": "只需要邮箱前缀", "err_email_format": "邮箱地址错误,请检查邮箱格式!", "err_title_must": "标题必填!", - "succ_send": "发送成功!", + "succ_send": "邮件已提交,正在投递", "outbox": "发件箱", "modify_pwd": "修改密码", "enter_again": "确认密码", diff --git a/frontend/src/views/List/List.vue b/frontend/src/views/List/List.vue index f4173b0..00175cb 100644 --- a/frontend/src/views/List/List.vue +++ b/frontend/src/views/List/List.vue @@ -78,14 +78,18 @@