This commit is contained in:
parent
cccdcb30eb
commit
2df35f3160
@ -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 {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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": "禁用",
|
||||
|
||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user