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 = '