From 2df35f3160dd36729554f9606e966369143db596 Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 24 Sep 2026 21:49:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=8D=89=E7=A8=BF=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=8F=91=E9=80=81=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=B9=B6=E4=BF=AE=E6=AD=A3=E7=BC=96=E8=BE=91=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/email/draft.go | 37 ++++--- backend/controllers/email/list.go | 4 + backend/controllers/email/send.go | 27 +++-- backend/dto/response/email.go | 1 + backend/listen/http_server/operation_log.go | 44 ++++---- backend/services/list/list.go | 2 +- frontend/src/i18n/i18n.js | 12 +++ frontend/src/views/Editer/Draft.js | 83 +++++++++------ frontend/src/views/Editer/Editer.css | 10 ++ frontend/src/views/Editer/Editer.js | 111 ++++++++++++-------- frontend/src/views/Editer/Editer.vue | 23 +++- frontend/src/views/List/List.js | 4 + frontend/src/views/List/List.vue | 2 +- 13 files changed, 228 insertions(+), 132 deletions(-) diff --git a/backend/controllers/email/draft.go b/backend/controllers/email/draft.go index 2da7de8..719c631 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 0ddc288..7762038 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 d4fa22a..5142585 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 4af07c4..ca95c77 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 419f156..d40aeae 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 b4b36ec..951259c 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 31fd967..1ee814e 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 6dd6b90..01cce74 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 = '


' - fileList.splice(0) - showCcBcc.value = false - sent = false - savedSnapshot = snapshot() - isLoading.value = false - return - } try { + const identity = await prepareIdentity() + if (disposed || sequence !== loadSequence) return + Object.assign(ruleForm, identity) + if (rawID === undefined) { + draftID.value = 0 + draftVersion.value = '' + replyTo.value = [] + envelopeSender = null + originalFrom = '' + recipientNames = new Map() + ruleForm.receivers = [] + ruleForm.cc = [] + ruleForm.bcc = [] + ruleForm.subject = '' + valueHtml.value = '


' + fileList.splice(0) + showCcBcc.value = false + sent.value = false + return + } + if (typeof rawID !== 'string' || !/^[1-9]\d*$/.test(rawID)) throw new Error(lang.draft_load_failed) 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}) @@ -76,22 +85,32 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi ruleForm.sender = message.from.email.slice(0, split) ruleForm.pickedDomain = message.from.email.slice(split + 1) ruleForm.nickName = message.from.name || '' + originalFrom = message.from.email + envelopeSender = message.sender || null + recipientNames = new Map( + [...(message.to || []), ...(message.cc || []), ...(message.bcc || [])] + .map(item => [item.email, item.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 || '


' + const plainText = (message.text || '').replace(/&/g, '&').replace(//g, '>').replace(/\r?\n/g, '
') + valueHtml.value = message.html || (plainText ? `

${plainText}

` : '


') fileList.splice(0, fileList.length, ...(message.attrs || [])) showCcBcc.value = ruleForm.cc.length > 0 || ruleForm.bcc.length > 0 - sent = false - savedSnapshot = snapshot() + sent.value = false } 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 + await nextTick() + if (!disposed && sequence === loadSequence) { + if (!loadFailed.value) savedSnapshot = snapshot() + isLoading.value = false + } } } @@ -119,7 +138,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi } const confirmLeave = async () => { - if (sent) return true + if (sent.value) return true if (isSaving.value || isSending.value || pendingFiles.value > 0) return false if (!isDirty()) return true try { @@ -137,10 +156,10 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi if (String(value || '') !== String(draftID.value || '')) loadDraft() }) watch([editorRef, isBusy], ([editor, busy]) => { - if (!editor || editor.isDestroyed) return + if (!editor || disposed) return if (busy) editor.disable() else editor.enable() - }) + }, {flush: 'post'}) const beforeUnload = event => { if (isDirty() || isSaving.value || isSending.value || pendingFiles.value > 0) { event.preventDefault() @@ -154,7 +173,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi window.removeEventListener('beforeunload', beforeUnload) }) const markSent = () => { - sent = true + sent.value = true groupStore.invalidate() } return {draftID, draftVersion, isSaving, isLoading, isBusy, loadFailed, loadDraft, saveDraft, buildMessage, markSent} diff --git a/frontend/src/views/Editer/Editer.css b/frontend/src/views/Editer/Editer.css index 4eee9a6..7ffc217 100644 --- a/frontend/src/views/Editer/Editer.css +++ b/frontend/src/views/Editer/Editer.css @@ -30,9 +30,19 @@ .composer-actions { display: flex; + flex-wrap: wrap; gap: 12px; } +.draft-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 20px; + color: var(--el-color-danger); +} + .action-btn { background: var(--pm-surface-glass); border: 1px solid var(--pm-border-color); diff --git a/frontend/src/views/Editer/Editer.js b/frontend/src/views/Editer/Editer.js index d10436c..d750846 100644 --- a/frontend/src/views/Editer/Editer.js +++ b/frontend/src/views/Editer/Editer.js @@ -1,4 +1,4 @@ -import './Editer.js' +import {useDraft} from './Draft' import '@wangeditor/editor/dist/css/style.css' @@ -92,25 +92,15 @@ export function usePage() { }) } - const init = function () { - loadContacts() - if (globalStatus.userInfos === null || globalStatus.userInfos == undefined || Object.keys(globalStatus.userInfos).length == 0){ - globalStatus.init(()=>{ - ruleForm.sender = globalStatus.userInfos.account - ruleForm.domains = globalStatus.userInfos.domains - ruleForm.pickedDomain = globalStatus.userInfos.domains[0] - ruleForm.nickName = globalStatus.userInfos.name - }) - }else{ - ruleForm.sender = globalStatus.userInfos.account - ruleForm.domains = globalStatus.userInfos.domains - ruleForm.pickedDomain = globalStatus.userInfos.domains[0] - ruleForm.nickName = globalStatus.userInfos.name - } + const prepareIdentity = async function () { + if (!globalStatus.userInfos?.account) await globalStatus.init() + const info = globalStatus.userInfos + if (!info?.account || !Array.isArray(info.domains) || !info.domains.length) { + throw new Error(lang.draft_identity_failed) + } + return {sender: info.account, domains: info.domains, pickedDomain: info.domains[0], nickName: info.name || ''} } - init() - const validateSender = function (rule, value, callback) { if (typeof ruleForm.sender === "undefined" || ruleForm.sender === null || ruleForm.sender.trim() === "") { callback(new Error(lang.err_sender_must)) @@ -180,34 +170,36 @@ export function usePage() { } const isSending = ref(false) + const pendingFiles = ref(0) + const readers = new Set() + const draft = useDraft({ + router, groupStore, ruleForm, valueHtml, fileList, editorRef, + showCcBcc, isSending, pendingFiles, prepareIdentity + }) + const init = () => { + loadContacts() + return draft.loadDraft() + } + init() + onBeforeUnmount(() => { + for (const reader of readers) reader.abort() + }) const send = function (formEl) { - if (!formEl || isSending.value) return + if (!formEl || draft.isBusy.value || !editorRef.value) return formEl.validate((valid) => { + if (draft.isBusy.value) return if (valid) { if(ruleForm.receivers.length === 0 && ruleForm.cc.length === 0 && ruleForm.bcc.length === 0) { ElMessage.warning("请至少填写一个收件人"); return; } - let objectTos = ruleForm.receivers.map(e => ({name: "", email: e})); - let objectCcs = ruleForm.cc.map(e => ({name: "", email: e})); - let objectBccs = ruleForm.bcc.map(e => ({name: "", email: e})); - - if (!editorRef.value) return - const text = editorRef.value.getText() + const message = draft.buildMessage() isSending.value = true - http.post("/api/v1/email/send", { - from: {name: ruleForm.nickName, email: ruleForm.sender + "@" + ruleForm.pickedDomain}, - to: objectTos, - cc: objectCcs, - bcc: objectBccs, - subject: ruleForm.subject, - text: text, - html: valueHtml.value, - attrs: fileList - }).then(res => { + http.post("/api/v1/email/send", message).then(res => { if (res.errorNo === 0) { + draft.markSent() ElMessage.success(lang.succ_send) groupStore.name = lang.outbox groupStore.tag = '{"type":1,"status":-1}' @@ -227,25 +219,48 @@ export function usePage() { } const upload = function () { - fileRef.value.dispatchEvent(new MouseEvent('click')) + if (draft.isBusy.value) return + fileRef.value?.click() } const fileChange = function (e) { - let files = e.target.files || e.dataTransfer.files; - if (!files.length) return; - for (let i = 0; i < files.length; i++) { - const reader = new FileReader(); - reader.onload = function fileReadCompleted() { + const files = Array.from(e.target.files || []) + e.target.value = '' + if (draft.isBusy.value || !files.length) return + if (fileList.length + files.length > 20 || files.some(file => file.size > 10 * 1024 * 1024)) { + ElMessage.warning(lang.draft_attachment_limit) + return + } + for (const file of files) { + const reader = new FileReader() + readers.add(reader) + pendingFiles.value += 1 + reader.onload = () => { + const data = String(reader.result || '') fileList.push({ - name: files[i].name, - data: this.result + name: file.name, + data: data.startsWith('data:;') + ? data.replace('data:;', 'data:application/octet-stream;') + : data }) - }; - reader.readAsDataURL(files[i]); + } + reader.onerror = () => ElMessage.error(lang.draft_attachment_failed) + reader.onloadend = () => { + readers.delete(reader) + pendingFiles.value -= 1 + } + try { + reader.readAsDataURL(file) + } catch { + readers.delete(reader) + pendingFiles.value -= 1 + ElMessage.error(lang.draft_attachment_failed) + } } } const delFile = function (index) { + if (draft.isBusy.value) return fileList.splice(index, 1); } @@ -281,6 +296,12 @@ export function usePage() { ruleForm, fileList, isSending, + isBusy: draft.isBusy, + isSaving: draft.isSaving, + isLoading: draft.isLoading, + loadFailed: draft.loadFailed, + loadDraft: draft.loadDraft, + saveDraft: draft.saveDraft, init, validateSender, checkEmail, diff --git a/frontend/src/views/Editer/Editer.vue b/frontend/src/views/Editer/Editer.vue index 5437976..bca4b16 100644 --- a/frontend/src/views/Editer/Editer.vue +++ b/frontend/src/views/Editer/Editer.vue @@ -3,18 +3,25 @@

{{ lang.compose }}

- + {{ lang.add_att }} - + + {{ lang.draft_save }} + + {{ lang.send }}
-
- + +
+
{{ lang.sender }}
@@ -32,7 +39,7 @@
前缀 @@ -172,6 +179,12 @@ const { fileList, delFile, isSending, + isBusy, + isSaving, + isLoading, + loadFailed, + loadDraft, + saveDraft, contactOptions, deleteContact } = usePage() diff --git a/frontend/src/views/List/List.js b/frontend/src/views/List/List.js index 67d8326..989a8e2 100644 --- a/frontend/src/views/List/List.js +++ b/frontend/src/views/List/List.js @@ -161,6 +161,10 @@ export function usePage() { }) const rowClick = function (row) { + if (row.editable_draft) { + router.push({name: 'editer', query: {draft: String(row.id)}}) + return + } router.push("/detail/" + row.id) } diff --git a/frontend/src/views/List/List.vue b/frontend/src/views/List/List.vue index 0fdb014..8044dd0 100644 --- a/frontend/src/views/List/List.vue +++ b/frontend/src/views/List/List.vue @@ -99,7 +99,7 @@
-
{{ scope.row.title }}
+
{{ scope.row.title || lang.mail_no_subject }}
{{ scope.row.desc }}