This commit is contained in:
parent
c38005384c
commit
cccdcb30eb
296
backend/controllers/email/draft.go
Normal file
296
backend/controllers/email/draft.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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": {},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
161
frontend/src/views/Editer/Draft.js
Normal file
161
frontend/src/views/Editer/Draft.js
Normal file
@ -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 = '<p><br></p>'
|
||||
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 || '<p><br></p>'
|
||||
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}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user