diff --git a/backend/controllers/email/draft.go b/backend/controllers/email/draft.go index 2da7de86..719c6312 100644 --- a/backend/controllers/email/draft.go +++ b/backend/controllers/email/draft.go @@ -26,6 +26,8 @@ import ( var errDraftConflict = errors.New("draft missing, changed or no longer editable") +const ownedDraftRelation = "EXISTS (SELECT 1 FROM user_email WHERE user_id = ? AND email_id = ? AND status = ? AND is_deleted = 0)" + var draftColumns = []string{ "subject", "reply_to", "from_name", "from_address", "to", "bcc", "cc", "text", "html", "sender", "attachments", "spf_check", "dkim_check", @@ -104,11 +106,11 @@ func buildDraft(ctx *context.Context, input sendRequest) (*models.Email, error) return result } message := &parsemail.Email{ - From: &parsemail.User{Name: input.From.Name, EmailAddress: input.From.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), + 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)), + Text: []byte(parsemail.SanitizeText(input.Text)), HTML: []byte(parsemail.SanitizeHTML(input.HTML)), } total := 0 for _, item := range input.Attachments { @@ -152,7 +154,7 @@ func buildDraft(ctx *context.Context, input sendRequest) (*models.Email, error) 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, + Size: len(message.Text) + len(message.HTML) + total, }, nil } @@ -160,16 +162,10 @@ func loadOwnedDraft(session *xorm.Session, userID, id int, version string) (*mod 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) + found, err := session.ID(id). + Where("send_user_id = ? AND type = ? AND status = ?", userID, consts.EmailTypeSend, consts.EmailStatusDrafts). + And(ownedDraftRelation, userID, id, consts.EmailStatusDrafts).Get(&draft) if err != nil { return nil, err } @@ -190,6 +186,7 @@ func replaceDraft(session *xorm.Session, userID, id int, version string, candida 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). + And(ownedDraftRelation, userID, id, consts.EmailStatusDrafts). Cols(draftColumns...).Update(candidate) if err != nil { return err @@ -234,10 +231,17 @@ func persistDraft(engine *xorm.Engine, userID int, draft *models.Email, id int, func draftMessage(draft models.Email) (sendRequest, error) { message := sendRequest{ DraftID: draft.Id, DraftVersion: draft.MsgID, - From: user{Name: draft.FromName, Email: draft.FromAddress}, + From: user{Name: draft.FromName, Email: draft.FromAddress}, Subject: draft.Subject, Text: draft.Text.String, HTML: draft.Html.String, Attachments: []attachment{}, } + var sender *parsemail.User + if err := json.Unmarshal([]byte(draft.Sender), &sender); err != nil { + return sendRequest{}, err + } + if sender != nil { + message.Sender = user{Name: sender.Name, Email: sender.EmailAddress} + } decodeUsers := func(value string, target *[]user) error { var users []parsemail.User if err := json.Unmarshal([]byte(value), &users); err != nil { @@ -249,7 +253,10 @@ func draftMessage(draft models.Email) (sendRequest, error) { } return nil } - for _, field := range []struct { value string; target *[]user }{ + 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 { diff --git a/backend/controllers/email/list.go b/backend/controllers/email/list.go index 0ddc288b..7762038d 100644 --- a/backend/controllers/email/list.go +++ b/backend/controllers/email/list.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "git.pcux.cn/xuans/Smail/consts" "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto" "git.pcux.cn/xuans/Smail/dto/parsemail" @@ -43,6 +44,7 @@ type emilItem struct { IsBlacklisted bool `json:"is_blacklisted"` Error string `json:"error"` Status int8 `json:"status"` + EditableDraft bool `json:"editable_draft"` } type User struct { @@ -140,6 +142,8 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { IsBlacklisted: isBlacklisted, Error: email.Error.String, Status: email.Status, + EditableDraft: email.Type == consts.EmailTypeSend && email.Status == consts.EmailStatusDrafts && + email.SendUserID == ctx.UserID && email.UserStatus == consts.EmailStatusDrafts && email.IsDeleted == 0, }) } diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go index d4fa22a6..5142585e 100644 --- a/backend/controllers/email/send.go +++ b/backend/controllers/email/send.go @@ -39,17 +39,17 @@ 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"` - Bcc []user `json:"bcc"` - Cc []user `json:"cc"` - Subject string `json:"subject"` - Text string `json:"text"` // Plaintext message (optional) - HTML string `json:"html"` // Html message (optional) - Sender user `json:"sender"` // override From as SMTP envelope sender (optional) - ReadReceipt []string `json:"read_receipt"` - Attachments []attachment `json:"attrs"` + ReplyTo []user `json:"reply_to"` + From user `json:"from"` + To []user `json:"to"` + Bcc []user `json:"bcc"` + Cc []user `json:"cc"` + Subject string `json:"subject"` + Text string `json:"text"` // Plaintext message (optional) + HTML string `json:"html"` // Html message (optional) + Sender user `json:"sender"` // override From as SMTP envelope sender (optional) + ReadReceipt []string `json:"read_receipt"` + Attachments []attachment `json:"attrs"` } type user struct { @@ -231,6 +231,10 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } now := time.Now() + messageSize := len(e.Text) + len(e.HTML) + for _, item := range e.Attachments { + messageSize += len(item.Content) + } modelEmail := models.Email{ Type: consts.EmailTypeSend, Subject: e.Subject, @@ -252,6 +256,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { Status: consts.EmailStatusDeliveryPending, CreateTime: now, MsgID: msgID, + Size: messageSize, } err = persistOutgoingEmailWithDraft(db.Instance, ctx.UserID, &modelEmail, reqData.DraftID, reqData.DraftVersion) diff --git a/backend/dto/response/email.go b/backend/dto/response/email.go index 4af07c48..ca95c770 100644 --- a/backend/dto/response/email.go +++ b/backend/dto/response/email.go @@ -6,6 +6,7 @@ type EmailResponseData struct { models.Email `xorm:"extends"` IsRead int8 `json:"is_read"` IsDeleted int8 `json:"-"` + UserStatus int8 `xorm:"user_status" json:"-"` SerialNumber int `json:"serial_number"` UeId int `json:"ue_id"` } diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index 419f156c..d40aeae6 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -15,26 +15,26 @@ import ( // loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。 var loggedOperations = map[string]string{ - "/api/email/draft/save": "保存邮件草稿", - "/api/login": "处理账号登录", - "/api/logout": "退出登录", - "/api/security/reauth": "完成身份重新认证", + "/api/email/draft/save": "保存邮件草稿", + "/api/login": "处理账号登录", + "/api/logout": "退出登录", + "/api/security/reauth": "完成身份重新认证", "/api/security/verification/request": "申请安全验证码", "/api/security/verification/verify": "完成安全验证", - "/api/security/login/verify": "完成登录二次验证", - "/api/security/login/resend": "重新发送登录验证码", - "/api/security/webauthn/registration/begin": "发起通行密钥注册", - "/api/security/webauthn/registration/finish": "完成通行密钥注册", - "/api/security/webauthn/credentials/rename": "重命名通行密钥", - "/api/security/webauthn/credentials/revoke": "撤销通行密钥", - "/api/security/webauthn/login/begin": "发起通行密钥登录", - "/api/security/webauthn/login/finish": "完成通行密钥登录验证", + "/api/security/login/verify": "完成登录二次验证", + "/api/security/login/resend": "重新发送登录验证码", + "/api/security/webauthn/registration/begin": "发起通行密钥注册", + "/api/security/webauthn/registration/finish": "完成通行密钥注册", + "/api/security/webauthn/credentials/rename": "重命名通行密钥", + "/api/security/webauthn/credentials/revoke": "撤销通行密钥", + "/api/security/webauthn/login/begin": "发起通行密钥登录", + "/api/security/webauthn/login/finish": "完成通行密钥登录验证", "/api/security/recovery/webauthn/begin": "发起通行密钥恢复账号", "/api/security/recovery/webauthn/verify": "通过通行密钥恢复账号", "/api/security/recovery/begin": "发起账号恢复", "/api/security/recovery/verify": "验证账号恢复凭据", "/api/security/recovery/complete": "完成账号恢复", - "/api/security/setup/acknowledge": "确认安全初始化引导", + "/api/security/setup/acknowledge": "确认安全初始化引导", "/api/security/credentials/question": "设置密保问题", "/api/security/credentials/recovery-codes/generate": "生成安全码", "/api/security/credentials/recovery-codes/revoke": "撤销安全码", @@ -75,20 +75,20 @@ var loggedOperations = map[string]string{ "/api/database/migrations": "执行数据库迁移", "/api/database/migrations/preview": "预览数据库迁移", "/api/setup": "执行系统初始化配置", - "/api/plugin/settings/telegram_push/user": "更新电报通知设置", - "/api/plugin/settings/telegram_push/test": "测试电报消息推送", - "/api/plugin/secrets/telegram_push": "更新电报插件密钥", - "/api/plugin/secrets/wechat_push": "更新微信插件密钥", + "/api/plugin/settings/telegram_push/user": "更新电报通知设置", + "/api/plugin/settings/telegram_push/test": "测试电报消息推送", + "/api/plugin/secrets/telegram_push": "更新电报插件密钥", + "/api/plugin/secrets/wechat_push": "更新微信插件密钥", } // readonlyPostEndpoints 明确排除属于查询性质的 POST 接口,防止误记操作记录。 var readonlyPostEndpoints = map[string]struct{}{ "/api/email/draft/detail": {}, - "/api/user/info": {}, - "/api/user/list": {}, - "/api/email/list": {}, - "/api/email/detail": {}, - "/api/rule/get": {}, + "/api/user/info": {}, + "/api/user/list": {}, + "/api/email/list": {}, + "/api/email/detail": {}, + "/api/rule/get": {}, } var configSectionNames = map[string]string{ diff --git a/backend/services/list/list.go b/backend/services/list/list.go index b4b36ec2..951259cf 100644 --- a/backend/services/list/list.go +++ b/backend/services/list/list.go @@ -46,7 +46,7 @@ func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword str } else if pop3List { sql += `e.id,e.size from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? ` } else { - sql += `e.*,ue.is_read from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? ` + sql += `e.*,ue.is_read,ue.is_deleted,ue.status AS user_status from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? ` } if tagInfo.Status != -1 { diff --git a/frontend/src/i18n/i18n.js b/frontend/src/i18n/i18n.js index 31fd9671..1ee814e6 100644 --- a/frontend/src/i18n/i18n.js +++ b/frontend/src/i18n/i18n.js @@ -1,5 +1,17 @@ // 管理后台统一使用中文。 const lang = { + "mail_no_subject": "(无主题)", + "draft_save": "保存草稿", + "draft_saved": "草稿已保存", + "draft_title": "未保存的邮件", + "draft_leave_warning": "邮件有未保存的修改,离开后这些修改将丢失。是否仍要离开?", + "draft_leave": "放弃修改并离开", + "draft_stay": "继续编辑", + "draft_load_failed": "草稿加载失败,请重试或返回草稿箱刷新后重新打开", + "draft_identity_failed": "无法获取发件人信息,请刷新页面后重试", + "draft_retry": "重新加载", + "draft_attachment_limit": "最多添加二十个附件,每个附件不得超过十兆字节", + "draft_attachment_failed": "附件读取失败,请重新选择文件", "logout": "注销", "resetPwd": "重置账号密码", "disabled": "禁用", diff --git a/frontend/src/views/Editer/Draft.js b/frontend/src/views/Editer/Draft.js index 6dd6b907..01cce74b 100644 --- a/frontend/src/views/Editer/Draft.js +++ b/frontend/src/views/Editer/Draft.js @@ -1,20 +1,23 @@ -import {computed, onBeforeUnmount, ref, watch} from 'vue' +import {computed, nextTick, 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}) { +export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, editorRef, showCcBcc, isSending, pendingFiles, prepareIdentity}) { const route = useRoute() const draftID = ref(0) const draftVersion = ref('') const isSaving = ref(false) const isLoading = ref(true) const loadFailed = ref(false) + const sent = ref(false) const replyTo = ref([]) - const isBusy = computed(() => isSaving.value || isSending.value || isLoading.value || loadFailed.value || pendingFiles.value > 0) + let envelopeSender = null + let originalFrom = '' + let recipientNames = new Map() + const isBusy = computed(() => sent.value || isSaving.value || isSending.value || isLoading.value || loadFailed.value || pendingFiles.value > 0) let savedSnapshot = '' - let sent = false let loadSequence = 0 let disposed = false @@ -22,9 +25,10 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi 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})), + to: ruleForm.receivers.map(email => ({name: recipientNames.get(email) || '', email})), + cc: ruleForm.cc.map(email => ({name: recipientNames.get(email) || '', email})), + bcc: ruleForm.bcc.map(email => ({name: recipientNames.get(email) || '', email})), + sender: envelopeSender?.email && envelopeSender.email !== originalFrom ? envelopeSender : undefined, reply_to: replyTo.value, subject: ruleForm.subject, text: editorRef.value?.getText() || '', @@ -37,30 +41,35 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi to: ruleForm.receivers, cc: ruleForm.cc, bcc: ruleForm.bcc, subject: ruleForm.subject, html: valueHtml.value, attrs: fileList }) - const isDirty = () => !sent && savedSnapshot !== '' && snapshot() !== savedSnapshot + const isDirty = () => !sent.value && 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 = '
${plainText}
` : '