补齐草稿保存恢复与发送流程并修正编辑权限判断
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-24 21:49:22 +08:00
parent cccdcb30eb
commit 2df35f3160
13 changed files with 228 additions and 132 deletions

View File

@ -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 {

View File

@ -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,
})
}

View File

@ -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)

View File

@ -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"`
}

View File

@ -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{

View File

@ -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 {

View File

@ -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": "禁用",

View File

@ -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 = '<p><br></p>'
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 = '<p><br></p>'
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 || '<p><br></p>'
const plainText = (message.text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\r?\n/g, '<br>')
valueHtml.value = message.html || (plainText ? `<p>${plainText}</p>` : '<p><br></p>')
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}

View File

@ -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);

View File

@ -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,

View File

@ -3,18 +3,25 @@
<div class="composer-header">
<h2 class="composer-title">{{ lang.compose }}</h2>
<div class="composer-actions">
<el-button class="action-btn" @click="upload">
<el-button class="action-btn" :disabled="isBusy" @click="upload">
<el-icon><Paperclip /></el-icon> {{ lang.add_att }}
</el-button>
<el-button type="primary" class="send-btn" :loading="isSending" :disabled="isSending" @click="send(ruleFormRef)">
<el-button class="action-btn" :loading="isSaving" :disabled="isBusy" @click="saveDraft">
<el-icon><Document /></el-icon> {{ lang.draft_save }}
</el-button>
<el-button type="primary" class="send-btn" :loading="isSending" :disabled="isBusy" @click="send(ruleFormRef)">
<el-icon><Position /></el-icon> {{ lang.send }}
</el-button>
<input v-show="false" ref="fileRef" type="file" @change="fileChange" multiple>
</div>
</div>
<div class="composer-body">
<el-form :rules="rules" ref="ruleFormRef" :model="ruleForm" class="composer-form">
<div v-if="loadFailed" class="draft-error" role="alert">
<span>{{ lang.draft_load_failed }}</span>
<el-button @click="loadDraft">{{ lang.draft_retry }}</el-button>
</div>
<div class="composer-body" v-loading="isLoading" :aria-busy="isLoading">
<el-form :disabled="isBusy" :rules="rules" ref="ruleFormRef" :model="ruleForm" class="composer-form">
<div class="compose-row">
<div class="field-label">{{ lang.sender }}</div>
@ -32,7 +39,7 @@
<div class="edit-row">
<span class="edit-label">前缀</span>
<el-input
:disabled="!(globalStatus.userInfos.is_admin)"
:disabled="isBusy || !(globalStatus.userInfos.is_admin)"
v-model="ruleForm.sender"
:placeholder="lang.sender_desc"
/>
@ -172,6 +179,12 @@ const {
fileList,
delFile,
isSending,
isBusy,
isSaving,
isLoading,
loadFailed,
loadDraft,
saveDraft,
contactOptions,
deleteContact
} = usePage()

View File

@ -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)
}

View File

@ -99,7 +99,7 @@
</template>
</div>
<div class="mail-subject">{{ scope.row.title }}</div>
<div class="mail-subject">{{ scope.row.title || lang.mail_no_subject }}</div>
<div class="mail-snippet">{{ scope.row.desc }}</div>
</div>
<div class="mail-meta">