From cccdcb30ebce1836120018ad1a6009e7b396c64e Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 24 Sep 2026 21:37:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E9=9B=86=E6=88=90=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD=E5=89=8D=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/email/draft.go | 296 ++++++++++++++++++++ backend/controllers/email/send.go | 42 ++- backend/listen/http_server/api_v1_compat.go | 2 + backend/listen/http_server/http_server.go | 2 + backend/listen/http_server/operation_log.go | 2 + backend/services/list/list.go | 2 +- frontend/src/views/Editer/Draft.js | 161 +++++++++++ 7 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 backend/controllers/email/draft.go create mode 100644 frontend/src/views/Editer/Draft.js diff --git a/backend/controllers/email/draft.go b/backend/controllers/email/draft.go new file mode 100644 index 0000000..2da7de8 --- /dev/null +++ b/backend/controllers/email/draft.go @@ -0,0 +1,296 @@ +package email + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + "unicode/utf8" + + "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" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/validation" + log "github.com/sirupsen/logrus" + "xorm.io/xorm" +) + +var errDraftConflict = errors.New("draft missing, changed or no longer editable") + +var draftColumns = []string{ + "subject", "reply_to", "from_name", "from_address", "to", "bcc", "cc", + "text", "html", "sender", "attachments", "spf_check", "dkim_check", + "status", "send_date", "cron_send_time", "msg_id", "size", "error", +} + +type draftReference struct { + ID int `json:"id"` + Version string `json:"version"` +} + +type draftResponse struct { + draftReference + Message sendRequest `json:"message"` +} + +func writeDraftError(w http.ResponseWriter, err error) { + if errors.Is(err, errDraftConflict) { + response.NewErrorResponse(response.ParamsError, "草稿已变更、已删除或已发送,请返回草稿箱刷新后重试", "").FPrint(w) + return + } + log.WithError(err).Error("draft operation failed") + response.NewErrorResponse(response.ServerError, "草稿操作失败,请稍后重试", "").FPrint(w) +} + +func SaveDraft(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var input sendRequest + if err := decodeEmailJSONRequest(w, req, &input, maxSendRequestBytes); err != nil { + response.NewErrorResponse(response.ParamsError, "草稿参数错误或内容超过限制", "").FPrint(w) + return + } + if input.DraftID < 0 || (input.DraftID == 0) != (input.DraftVersion == "") { + response.NewErrorResponse(response.ParamsError, "草稿标识或版本错误", "").FPrint(w) + return + } + candidate, err := buildDraft(ctx, input) + if err != nil { + response.NewErrorResponse(response.ParamsError, "草稿内容无效,请检查发件人、邮箱地址及附件大小", "").FPrint(w) + return + } + if err := persistDraft(db.Instance, ctx.UserID, candidate, input.DraftID, input.DraftVersion); err != nil { + writeDraftError(w, err) + return + } + response.NewSuccessResponse(draftReference{ID: candidate.Id, Version: candidate.MsgID}).FPrint(w) +} + +func buildDraft(ctx *context.Context, input sendRequest) (*models.Email, error) { + domains := config.CurrentMailDomains() + if domains.Primary == "" || ctx.UserID <= 0 { + return nil, validation.ErrInvalidEmail + } + if input.From.Email == "" { + input.From.Email = ctx.UserAccount + "@" + domains.Primary + } + if input.From.Name == "" { + input.From.Name = ctx.UserName + } + if input.Sender.Email == "" { + input.Sender = input.From + } + allowAlias := ctx.EffectiveRole() == access.RoleSuperAdmin + if !validation.IsAuthorizedSender(input.From.Email, ctx.UserAccount, allowAlias, domains.Accepted) || + !validation.IsAuthorizedSender(input.Sender.Email, ctx.UserAccount, allowAlias, domains.Accepted) { + return nil, validation.ErrInvalidEmail + } + if utf8.RuneCountInString(input.Subject) > 1000 || utf8.RuneCountInString(input.From.Name) > 50 || + len(input.Text)+len(input.HTML) > maxSendBodyBytes || len(input.Attachments) > maxAttachmentCount { + return nil, validation.ErrEmailTooLarge + } + convert := func(users []user) []*parsemail.User { + result := make([]*parsemail.User, 0, len(users)) + for _, recipient := range users { + result = append(result, &parsemail.User{Name: recipient.Name, EmailAddress: recipient.Email}) + } + return result + } + message := &parsemail.Email{ + From: &parsemail.User{Name: input.From.Name, EmailAddress: input.From.Email}, + Sender: &parsemail.User{Name: input.Sender.Name, EmailAddress: input.Sender.Email}, + To: convert(input.To), Cc: convert(input.Cc), Bcc: convert(input.Bcc), ReplyTo: convert(input.ReplyTo), + Subject: parsemail.SanitizeText(input.Subject), + Text: []byte(parsemail.SanitizeText(input.Text)), HTML: []byte(parsemail.SanitizeHTML(input.HTML)), + } + total := 0 + for _, item := range input.Attachments { + contentType, content, err := decodeAttachment(item.Data) + if err != nil { + return nil, err + } + if len(content) > maxAttachmentBytes || total > maxTotalAttachmentSize-len(content) { + return nil, validation.ErrAttachmentTooLarge + } + total += len(content) + message.Attachments = append(message.Attachments, &parsemail.Attachment{ + Filename: safeAttachmentName(item.Name), ContentType: contentType, Content: content, + }) + } + // 草稿允许暂未填写收件人;占位地址仅用于复用安全校验,不写入草稿。 + checked := *message + if len(checked.To)+len(checked.Cc)+len(checked.Bcc)+len(checked.ReplyTo) == 0 { + checked.To = []*parsemail.User{message.From} + } + if err := validation.ValidateEmail(&checked, validation.EmailLimits{ + MaxRecipients: maxRecipientCount, MaxAttachments: maxAttachmentCount, + MaxAttachmentBytes: maxAttachmentBytes, MaxTotalAttachmentBytes: maxTotalAttachmentSize, + MaxBodyBytes: maxSendBodyBytes, MaxEstimatedBytes: maxSendRequestBytes, + }); err != nil { + return nil, err + } + fields, err := message.EncodeStoredFields() + if err != nil { + return nil, err + } + version, err := parsemail.GenerateMsgID(domains.Primary) + if err != nil { + return nil, err + } + now := time.Now() + return &models.Email{ + Type: consts.EmailTypeSend, Status: consts.EmailStatusDrafts, SendUserID: ctx.UserID, + Subject: message.Subject, FromName: message.From.Name, FromAddress: message.From.EmailAddress, + ReplyTo: fields.ReplyTo, To: fields.To, Cc: fields.Cc, Bcc: fields.Bcc, Sender: fields.Sender, + Text: sql.NullString{String: string(message.Text), Valid: true}, + Html: sql.NullString{String: string(message.HTML), Valid: true}, Attachments: fields.Attachments, + SPFCheck: 1, DKIMCheck: 1, MsgID: version, CreateTime: now, SendDate: now, + Size: len(message.Text)+len(message.HTML)+total, + }, nil +} + +func loadOwnedDraft(session *xorm.Session, userID, id int, version string) (*models.Email, error) { + if userID <= 0 || id <= 0 { + return nil, errDraftConflict + } + var relation models.UserEmail + found, err := session.Where("user_id = ? AND email_id = ? AND status = ? AND is_deleted = 0", userID, id, consts.EmailStatusDrafts).Get(&relation) + if err != nil { + return nil, err + } + if !found { + return nil, errDraftConflict + } + var draft models.Email + found, err = session.ID(id).Where("send_user_id = ? AND type = ? AND status = ?", userID, consts.EmailTypeSend, consts.EmailStatusDrafts).Get(&draft) + if err != nil { + return nil, err + } + if !found || (version != "" && version != draft.MsgID) { + return nil, errDraftConflict + } + return &draft, nil +} + +func replaceDraft(session *xorm.Session, userID, id int, version string, candidate *models.Email) error { + if version == "" { + return errDraftConflict + } + previous, err := loadOwnedDraft(session, userID, id, version) + if err != nil { + return err + } + candidate.Id = previous.Id + candidate.CreateTime = previous.CreateTime + affected, err := session.ID(id).Where("send_user_id = ? AND status = ? AND msg_id = ?", userID, consts.EmailStatusDrafts, version). + Cols(draftColumns...).Update(candidate) + if err != nil { + return err + } + if affected != 1 { + return errDraftConflict + } + return nil +} + +func persistDraft(engine *xorm.Engine, userID int, draft *models.Email, id int, version string) error { + var saved models.Email + err := db.Transaction(engine, func(session *xorm.Session) error { + candidate := *draft + if id > 0 { + if err := replaceDraft(session, userID, id, version, &candidate); err != nil { + return err + } + } else { + candidate.Id = 0 + if _, err := session.Insert(&candidate); err != nil { + return err + } + if candidate.Id <= 0 { + return fmt.Errorf("insert draft returned no identifier") + } + if _, err := session.Insert(&models.UserEmail{ + UserID: userID, EmailID: candidate.Id, Status: consts.EmailStatusDrafts, IsRead: 1, + }); err != nil { + return err + } + } + saved = candidate + return nil + }) + if err == nil { + *draft = saved + } + return err +} + +func draftMessage(draft models.Email) (sendRequest, error) { + message := sendRequest{ + DraftID: draft.Id, DraftVersion: draft.MsgID, + From: user{Name: draft.FromName, Email: draft.FromAddress}, + Subject: draft.Subject, Text: draft.Text.String, HTML: draft.Html.String, + Attachments: []attachment{}, + } + decodeUsers := func(value string, target *[]user) error { + var users []parsemail.User + if err := json.Unmarshal([]byte(value), &users); err != nil { + return err + } + *target = make([]user, 0, len(users)) + for _, recipient := range users { + *target = append(*target, user{Name: recipient.Name, Email: recipient.EmailAddress}) + } + return nil + } + for _, field := range []struct { value string; target *[]user }{ + {draft.To, &message.To}, {draft.Cc, &message.Cc}, {draft.Bcc, &message.Bcc}, {draft.ReplyTo, &message.ReplyTo}, + } { + if err := decodeUsers(field.value, field.target); err != nil { + return sendRequest{}, err + } + } + var items []parsemail.Attachment + if err := json.Unmarshal([]byte(draft.Attachments), &items); err != nil { + return sendRequest{}, err + } + for _, item := range items { + if strings.ContainsAny(item.ContentType, "\r\n,") { + return sendRequest{}, validation.ErrInvalidEmail + } + message.Attachments = append(message.Attachments, attachment{ + Name: item.Filename, Data: "data:" + item.ContentType + ";base64," + base64.StdEncoding.EncodeToString(item.Content), + }) + } + return message, nil +} + +func ReadDraft(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + var input draftReference + if err := decodeEmailJSONRequest(w, req, &input, maxEmailJSONRequestBytes); err != nil || input.ID <= 0 { + response.NewErrorResponse(response.ParamsError, "草稿标识错误", "").FPrint(w) + return + } + session := db.Instance.NewSession() + defer session.Close() + draft, err := loadOwnedDraft(session, ctx.UserID, input.ID, "") + if err != nil { + writeDraftError(w, err) + return + } + message, err := draftMessage(*draft) + if err != nil { + writeDraftError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + response.NewSuccessResponse(draftResponse{ + draftReference: draftReference{ID: draft.Id, Version: draft.MsgID}, Message: message, + }).FPrint(w) +} diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go index 527e644..d4fa22a 100644 --- a/backend/controllers/email/send.go +++ b/backend/controllers/email/send.go @@ -3,6 +3,7 @@ package email import ( "database/sql" "encoding/base64" + "errors" "fmt" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/consts" @@ -36,6 +37,8 @@ const ( ) type sendRequest struct { + DraftID int `json:"draft_id"` + DraftVersion string `json:"draft_version"` ReplyTo []user `json:"reply_to"` From user `json:"from"` To []user `json:"to"` @@ -73,6 +76,10 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } log.WithContext(ctx).Infof("发送邮件") + if reqData.DraftID < 0 || (reqData.DraftID == 0) != (reqData.DraftVersion == "") { + writeDraftError(w, errDraftConflict) + return + } allowLocalAlias := ctx.EffectiveRole() == access.RoleSuperAdmin if reqData.From.Email != "" && !validation.IsAuthorizedSender(reqData.From.Email, ctx.UserAccount, allowLocalAlias, domains.Accepted) { response.NewErrorResponse(response.ParamsError, "发件人地址不允许", "").FPrint(w) @@ -247,7 +254,11 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { MsgID: msgID, } - err = persistOutgoingEmail(db.Instance, ctx.UserID, &modelEmail) + err = persistOutgoingEmailWithDraft(db.Instance, ctx.UserID, &modelEmail, reqData.DraftID, reqData.DraftVersion) + if errors.Is(err, errDraftConflict) { + writeDraftError(w, err) + return + } if err != nil || modelEmail.Id <= 0 { if err != nil { @@ -275,13 +286,23 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } func persistOutgoingEmail(engine *xorm.Engine, userID int, email *models.Email) error { + return persistOutgoingEmailWithDraft(engine, userID, email, 0, "") +} + +func persistOutgoingEmailWithDraft(engine *xorm.Engine, userID int, email *models.Email, draftID int, version string) error { var saved models.Email err := 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 draftID > 0 { + if err := replaceDraft(session, userID, draftID, version, &candidate); err != nil { + return err + } + } else { + if _, err := session.Insert(&candidate); err != nil { + return err + } } if candidate.Id <= 0 { return fmt.Errorf("insert outgoing email returned no identifier") @@ -292,8 +313,19 @@ func persistOutgoingEmail(engine *xorm.Engine, userID int, email *models.Email) Status: consts.EmailStatusDeliveryPending, IsRead: 1, } - if _, err := session.Insert(&relation); err != nil { - return err + if draftID > 0 { + affected, err := session.Where("user_id = ? AND email_id = ? AND status = ? AND is_deleted = 0", userID, draftID, consts.EmailStatusDrafts). + Cols("status", "group_id", "is_read").Update(&relation) + if err != nil { + return err + } + if affected != 1 { + return errDraftConflict + } + } else { + if _, err := session.Insert(&relation); err != nil { + return err + } } if err := outbox.Enqueue(session, candidate); err != nil { return err diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index f4e3270..ea26eea 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -66,6 +66,8 @@ func registerV1Routes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/email/detail", "/api/email/detail", http.MethodPost, 256<<10, email.EmailDetail) registerV1MethodRoute(mux, "/api/v1/email/move", "/api/email/move", http.MethodPost, 256<<10, email.Move) registerV1MethodRoute(mux, "/api/v1/email/send", "/api/email/send", http.MethodPost, 40<<20, email.Send) + registerV1MethodRoute(mux, "/api/v1/email/draft/save", "/api/email/draft/save", http.MethodPost, 40<<20, email.SaveDraft) + registerV1MethodRoute(mux, "/api/v1/email/draft/detail", "/api/email/draft/detail", http.MethodPost, 64<<10, email.ReadDraft) registerV1MethodRoute(mux, "/api/v1/email/contacts", "/api/email/contacts", http.MethodGet, 0, email.ContactsList) registerV1MethodRoute(mux, "/api/v1/email/contacts/delete", "/api/email/contacts/delete", http.MethodPost, 16<<10, email.DeleteContact) registerV1MethodRoute(mux, "/api/v1/email/trust-sender", "/api/email/trust-sender", http.MethodPost, 16<<10, email.TrustSender) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 25ec65f..26b033c 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -106,6 +106,8 @@ func router(mux *http.ServeMux) error { mux.HandleFunc("/api/email/detail", onlyMethod(http.MethodPost, limitBody(256<<10, contextIterceptor(email.EmailDetail)))) mux.HandleFunc("/api/email/move", onlyMethod(http.MethodPost, limitBody(256<<10, contextIterceptor(email.Move)))) mux.HandleFunc("/api/email/send", onlyMethod(http.MethodPost, limitBody(40<<20, contextIterceptor(email.Send)))) + mux.HandleFunc("/api/email/draft/save", onlyMethod(http.MethodPost, limitBody(40<<20, contextIterceptor(email.SaveDraft)))) + mux.HandleFunc("/api/email/draft/detail", onlyMethod(http.MethodPost, limitBody(64<<10, contextIterceptor(email.ReadDraft)))) mux.HandleFunc("/api/email/contacts", onlyMethod(http.MethodGet, contextIterceptor(email.ContactsList))) mux.HandleFunc("/api/email/contacts/delete", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.DeleteContact)))) mux.HandleFunc("/api/email/trust-sender", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(email.TrustSender)))) diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index 861c63a..419f156 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -15,6 +15,7 @@ import ( // loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。 var loggedOperations = map[string]string{ + "/api/email/draft/save": "保存邮件草稿", "/api/login": "处理账号登录", "/api/logout": "退出登录", "/api/security/reauth": "完成身份重新认证", @@ -82,6 +83,7 @@ var loggedOperations = map[string]string{ // readonlyPostEndpoints 明确排除属于查询性质的 POST 接口,防止误记操作记录。 var readonlyPostEndpoints = map[string]struct{}{ + "/api/email/draft/detail": {}, "/api/user/info": {}, "/api/user/list": {}, "/api/email/list": {}, diff --git a/backend/services/list/list.go b/backend/services/list/list.go index 34f1a24..b4b36ec 100644 --- a/backend/services/list/list.go +++ b/backend/services/list/list.go @@ -67,7 +67,7 @@ func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword str sql += " and ue.status = 0" } else { // 发件箱不展示已删除的邮件 - sql += " and ue.status != 3" + sql += " and ue.status NOT IN (3, 4) AND e.status != 4" } } diff --git a/frontend/src/views/Editer/Draft.js b/frontend/src/views/Editer/Draft.js new file mode 100644 index 0000000..6dd6b90 --- /dev/null +++ b/frontend/src/views/Editer/Draft.js @@ -0,0 +1,161 @@ +import {computed, onBeforeUnmount, ref, watch} from 'vue' +import {onBeforeRouteLeave, onBeforeRouteUpdate, useRoute} from 'vue-router' +import {ElMessage, ElMessageBox} from 'element-plus' +import {http} from '@/utils/axios' +import lang from '../../i18n/i18n' + +export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, editorRef, showCcBcc, isSending, pendingFiles}) { + const route = useRoute() + const draftID = ref(0) + const draftVersion = ref('') + const isSaving = ref(false) + const isLoading = ref(true) + const loadFailed = ref(false) + const replyTo = ref([]) + const isBusy = computed(() => isSaving.value || isSending.value || isLoading.value || loadFailed.value || pendingFiles.value > 0) + let savedSnapshot = '' + let sent = false + let loadSequence = 0 + let disposed = false + + const buildMessage = () => ({ + draft_id: draftID.value, + draft_version: draftVersion.value, + from: {name: ruleForm.nickName, email: `${ruleForm.sender}@${ruleForm.pickedDomain}`}, + to: ruleForm.receivers.map(email => ({name: '', email})), + cc: ruleForm.cc.map(email => ({name: '', email})), + bcc: ruleForm.bcc.map(email => ({name: '', email})), + reply_to: replyTo.value, + subject: ruleForm.subject, + text: editorRef.value?.getText() || '', + html: valueHtml.value, + attrs: fileList.map(item => ({name: item.name, data: item.data})) + }) + + const snapshot = () => JSON.stringify({ + from: [ruleForm.nickName, ruleForm.sender, ruleForm.pickedDomain], + to: ruleForm.receivers, cc: ruleForm.cc, bcc: ruleForm.bcc, + subject: ruleForm.subject, html: valueHtml.value, attrs: fileList + }) + const isDirty = () => !sent && savedSnapshot !== '' && snapshot() !== savedSnapshot + + const loadDraft = async () => { + const sequence = ++loadSequence + isLoading.value = true + loadFailed.value = false + const rawID = route.query.draft + if (rawID === undefined) { + draftID.value = 0 + draftVersion.value = '' + replyTo.value = [] + ruleForm.receivers = [] + ruleForm.cc = [] + ruleForm.bcc = [] + ruleForm.subject = '' + valueHtml.value = '


' + fileList.splice(0) + showCcBcc.value = false + sent = false + savedSnapshot = snapshot() + isLoading.value = false + return + } + try { + const id = Number(rawID) + if (!Number.isSafeInteger(id) || id <= 0) throw new Error(lang.draft_load_failed) + const result = await http.post('/api/v1/email/draft/detail', {id}) + if (disposed || sequence !== loadSequence) return + if (result.errorNo !== 0) throw new Error(result.errorMsg || lang.draft_load_failed) + const data = result.data + const message = data?.message + if (data?.id !== id || !data.version || !message?.from?.email) throw new Error(lang.draft_load_failed) + const split = message.from.email.lastIndexOf('@') + if (split <= 0) throw new Error(lang.draft_load_failed) + draftID.value = data.id + draftVersion.value = data.version + ruleForm.sender = message.from.email.slice(0, split) + ruleForm.pickedDomain = message.from.email.slice(split + 1) + ruleForm.nickName = message.from.name || '' + ruleForm.receivers = (message.to || []).map(item => item.email) + ruleForm.cc = (message.cc || []).map(item => item.email) + ruleForm.bcc = (message.bcc || []).map(item => item.email) + replyTo.value = message.reply_to || [] + ruleForm.subject = message.subject || '' + valueHtml.value = message.html || '


' + fileList.splice(0, fileList.length, ...(message.attrs || [])) + showCcBcc.value = ruleForm.cc.length > 0 || ruleForm.bcc.length > 0 + sent = false + savedSnapshot = snapshot() + } catch (error) { + if (disposed || sequence !== loadSequence) return + loadFailed.value = true + ElMessage.error(error.message || lang.draft_load_failed) + } finally { + if (!disposed && sequence === loadSequence) isLoading.value = false + } + } + + const saveDraft = async () => { + if (isBusy.value || !editorRef.value) return + isSaving.value = true + const submittedSnapshot = snapshot() + try { + const result = await http.post('/api/v1/email/draft/save', buildMessage()) + if (result.errorNo !== 0) throw new Error(result.errorMsg || lang.fail) + if (!Number.isSafeInteger(result.data?.id) || result.data.id <= 0 || !result.data.version) throw new Error(lang.fail) + draftID.value = result.data.id + draftVersion.value = result.data.version + savedSnapshot = submittedSnapshot + groupStore.invalidate() + // 将已保存草稿写入地址,刷新后仍可恢复;先释放保存锁再更新路由。 + isSaving.value = false + await router.replace({name: 'editer', query: {draft: String(draftID.value)}}) + ElMessage.success(lang.draft_saved) + } catch (error) { + ElMessage.error(error.message || lang.fail) + } finally { + isSaving.value = false + } + } + + const confirmLeave = async () => { + if (sent) return true + if (isSaving.value || isSending.value || pendingFiles.value > 0) return false + if (!isDirty()) return true + try { + await ElMessageBox.confirm(lang.draft_leave_warning, lang.draft_title, { + confirmButtonText: lang.draft_leave, cancelButtonText: lang.draft_stay, type: 'warning' + }) + return true + } catch { + return false + } + } + onBeforeRouteLeave(confirmLeave) + onBeforeRouteUpdate((to, from) => to.query.draft === from.query.draft ? true : confirmLeave()) + watch(() => route.query.draft, value => { + if (String(value || '') !== String(draftID.value || '')) loadDraft() + }) + watch([editorRef, isBusy], ([editor, busy]) => { + if (!editor || editor.isDestroyed) return + if (busy) editor.disable() + else editor.enable() + }) + const beforeUnload = event => { + if (isDirty() || isSaving.value || isSending.value || pendingFiles.value > 0) { + event.preventDefault() + event.returnValue = '' + } + } + window.addEventListener('beforeunload', beforeUnload) + onBeforeUnmount(() => { + disposed = true + loadSequence += 1 + window.removeEventListener('beforeunload', beforeUnload) + }) + const markSent = () => { + sent = true + groupStore.invalidate() + } + return {draftID, draftVersion, isSaving, isLoading, isBusy, loadFailed, loadDraft, saveDraft, buildMessage, markSent} +}