保存邮件回复链路接入界面前的开发检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-27 20:48:33 +08:00
parent e1c44bbe33
commit d5073cccbe
14 changed files with 361 additions and 111 deletions

View File

@ -29,6 +29,7 @@ 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{
"reply_email_id", "in_reply_to", "thread_references",
"subject", "reply_to", "from_name", "from_address", "to", "bcc", "cc",
"text", "html", "sender", "attachments", "spf_check", "dkim_check",
"status", "send_date", "cron_send_time", "msg_id", "size", "error",
@ -113,6 +114,9 @@ func buildDraft(ctx *context.Context, input sendRequest) (*models.Email, error)
Text: []byte(parsemail.SanitizeText(input.Text)), HTML: []byte(parsemail.SanitizeHTML(input.HTML)),
}
total := 0
if err := applyReplyThread(ctx, input, message); err != nil {
return nil, err
}
for _, item := range input.Attachments {
contentType, content, err := decodeAttachment(item.Data)
if err != nil {
@ -149,6 +153,7 @@ func buildDraft(ctx *context.Context, input sendRequest) (*models.Email, error)
now := time.Now()
return &models.Email{
Type: consts.EmailTypeSend, Status: consts.EmailStatusDrafts, SendUserID: ctx.UserID,
ReplyEmailID: input.ReplyEmailID, InReplyTo: message.InReplyTo, ThreadReferences: message.ThreadReferences,
Subject: message.Subject, FromName: message.From.Name, FromAddress: message.From.EmailAddress,
ReplyTo: fields.ReplyTo, To: fields.To, Cc: fields.Cc, Bcc: fields.Bcc, Sender: fields.Sender,
Text: sql.NullString{String: string(message.Text), Valid: true},
@ -231,8 +236,9 @@ 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},
Subject: draft.Subject, Text: draft.Text.String, HTML: draft.Html.String,
ReplyEmailID: draft.ReplyEmailID,
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

View File

@ -0,0 +1,50 @@
package email
import (
"git.pcux.cn/xuans/Smail/consts"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/validation"
)
func applyReplyThread(ctx *context.Context, input sendRequest, message *parsemail.Email) error {
id := input.ReplyEmailID
if message == nil {
return validation.ErrInvalidEmail
}
message.InReplyTo, message.ThreadReferences = "", ""
if id == 0 {
return nil
}
if id < 0 || ctx == nil || ctx.UserID <= 0 || db.Instance == nil || message == nil {
return validation.ErrInvalidEmail
}
if input.DraftID > 0 {
session := db.Instance.NewSession()
draft, err := loadOwnedDraft(session, ctx.UserID, input.DraftID, input.DraftVersion)
session.Close()
if err != nil {
return err
}
if draft.ReplyEmailID == id {
message.InReplyTo = parsemail.NormalizeThreadHeader(draft.InReplyTo)
message.ThreadReferences = parsemail.NormalizeThreadHeader(draft.ThreadReferences)
return nil
}
}
var original models.Email
found, err := db.Instance.ID(id).
Where("status <> ?", consts.EmailStatusDrafts).
And("EXISTS (SELECT 1 FROM user_email WHERE email_id = ? AND user_id = ? AND is_deleted = 0)", id, ctx.UserID).
Get(&original)
if err != nil {
return err
}
if !found {
return validation.ErrInvalidEmail
}
message.InReplyTo, message.ThreadReferences = parsemail.ReplyThread(original.MsgID, original.ThreadReferences, original.InReplyTo)
return nil
}

View File

@ -37,6 +37,7 @@ const (
)
type sendRequest struct {
ReplyEmailID int `json:"reply_email_id"`
DraftID int `json:"draft_id"`
DraftVersion string `json:"draft_version"`
ReplyTo []user `json:"reply_to"`
@ -122,6 +123,11 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
e := &parsemail.Email{}
if err := applyReplyThread(ctx, reqData, e); err != nil {
response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "send_fail"), "").FPrint(w)
return
}
inReplyTo, threadReferences := e.InReplyTo, e.ThreadReferences
for _, to := range reqData.To {
e.To = append(e.To, &parsemail.User{
@ -216,6 +222,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return
}
e.InReplyTo, e.ThreadReferences = inReplyTo, threadReferences
msgID, err := parsemail.GenerateMsgID(domains.Primary)
if err != nil {
log.WithContext(ctx).WithError(err).Error("generate outgoing message ID failed")
@ -236,27 +243,30 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
messageSize += len(item.Content)
}
modelEmail := models.Email{
Type: consts.EmailTypeSend,
Subject: e.Subject,
ReplyTo: storedFields.ReplyTo,
FromName: e.From.Name,
FromAddress: e.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(e.Text), Valid: true},
Html: sql.NullString{String: string(e.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
SPFCheck: 1,
DKIMCheck: 1,
SendUserID: ctx.UserID,
SendDate: now,
CronSendTime: now,
Status: consts.EmailStatusDeliveryPending,
CreateTime: now,
MsgID: msgID,
Size: messageSize,
Type: consts.EmailTypeSend,
Subject: e.Subject,
ReplyTo: storedFields.ReplyTo,
ReplyEmailID: reqData.ReplyEmailID,
InReplyTo: e.InReplyTo,
ThreadReferences: e.ThreadReferences,
FromName: e.From.Name,
FromAddress: e.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(e.Text), Valid: true},
Html: sql.NullString{String: string(e.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
SPFCheck: 1,
DKIMCheck: 1,
SendUserID: ctx.UserID,
SendDate: now,
CronSendTime: now,
Status: consts.EmailStatusDeliveryPending,
CreateTime: now,
MsgID: msgID,
Size: messageSize,
}
err = persistOutgoingEmailWithDraft(db.Instance, ctx.UserID, &modelEmail, reqData.DraftID, reqData.DraftVersion)

View File

@ -0,0 +1,27 @@
package migration
import (
"context"
"fmt"
)
func applyEmailThreadSchema(ctx context.Context, exec executor, dialect Dialect) error {
var idDefinition string
switch dialect {
case DialectSQLite:
idDefinition = "INTEGER NOT NULL DEFAULT 0"
case DialectMySQL, DialectPostgres:
idDefinition = "BIGINT NOT NULL DEFAULT 0"
default:
return fmt.Errorf("unsupported email thread dialect %q", dialect)
}
if err := ensureColumn(ctx, exec, dialect, "email", "reply_email_id", idDefinition); err != nil {
return err
}
for _, column := range []string{"in_reply_to", "thread_references"} {
if err := ensureColumn(ctx, exec, dialect, "email", column, "TEXT NULL"); err != nil {
return err
}
}
return nil
}

View File

@ -43,6 +43,7 @@ func DefaultMigrations() []Migration {
DefineForDialects(26, "create_account_groups", accountGroupStatements()),
DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema),
DefineCustom(28, "expand_recovery_code_ciphertext", "expand_recovery_code_ciphertext_v1", applyRecoveryCodeCiphertextSchema),
DefineCustom(29, "expand_email_thread", "expand_email_thread_v1", applyEmailThreadSchema),
}
}

View File

@ -73,25 +73,27 @@ func NewEmailAuthentication(spfPassed, dkimPassed bool) *EmailAuthentication {
// Email is the type used for email messages
type Email struct {
EnvelopeFrom string
ReplyTo []*User
From *User
To []*User
Bcc []*User
Cc []*User
Subject string
Text []byte // Plaintext message (optional)
HTML []byte // Html message (optional)
Sender *User // override From as SMTP envelope sender (optional)
Headers textproto.MIMEHeader
Attachments []*Attachment
ReadReceipt []string
Date string
Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件
MessageId int64
MsgID string // RFC-compliant Message-ID, persisted in DB
Size int
Authentication *EmailAuthentication
InReplyTo string
ThreadReferences string
EnvelopeFrom string
ReplyTo []*User
From *User
To []*User
Bcc []*User
Cc []*User
Subject string
Text []byte // Plaintext message (optional)
HTML []byte // Html message (optional)
Sender *User // override From as SMTP envelope sender (optional)
Headers textproto.MIMEHeader
Attachments []*Attachment
ReadReceipt []string
Date string
Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件
MessageId int64
MsgID string // RFC-compliant Message-ID, persisted in DB
Size int
Authentication *EmailAuthentication
}
func (e *Email) SenderFilterAddresses() []string {
@ -344,8 +346,10 @@ func NewEmailFromModel(d models.Email) (*Email, error) {
}
ret := &Email{
MessageId: cast.ToInt64(d.Id),
MsgID: d.MsgID,
MessageId: cast.ToInt64(d.Id),
InReplyTo: NormalizeThreadHeader(d.InReplyTo),
ThreadReferences: NormalizeThreadHeader(d.ThreadReferences),
MsgID: d.MsgID,
From: &User{
Name: d.FromName,
EmailAddress: d.FromAddress,
@ -387,6 +391,8 @@ func NewEmailFromReader(to []string, r io.Reader, size int) (*Email, error) {
}
ret.Size = size
ret.InReplyTo = NormalizeThreadHeader(m.Header.Get("In-Reply-To"))
ret.ThreadReferences = NormalizeThreadHeader(m.Header.Get("References"))
// Preserve the original Message-ID from the sender so it is stored and reused consistently.
if mid := m.Header.Get("Message-Id"); mid != "" {
ret.MsgID = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(mid), ">"), "<")
@ -845,6 +851,8 @@ func (e *Email) BuildBytes(ctx *context.Context, dkim bool) ([]byte, error) {
h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, cfg.Domain))
}
h.SetAddressList("From", from)
h.SetMsgIDList("In-Reply-To", ThreadMessageIDs(e.InReplyTo))
h.SetMsgIDList("References", ThreadMessageIDs(e.ThreadReferences))
h.SetAddressList("Sender", from)
h.SetAddressList("To", to)
h.SetText("Subject", e.Subject)

View File

@ -0,0 +1,57 @@
package parsemail
import (
"strings"
"github.com/emersion/go-message/mail"
)
func ThreadMessageIDs(value string) []string {
if value == "" || len(value) > 32768 || strings.ContainsAny(value, "\r\n\x00") {
return nil
}
var header mail.Header
header.Set("References", value)
ids, err := header.MsgIDList("References")
if err != nil {
return nil
}
result := make([]string, 0, len(ids))
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if len(id) > 254 || !strings.Contains(id, "@") || strings.ContainsAny(id, "<>\r\n\x00\t ") {
continue
}
if strings.IndexFunc(id, func(r rune) bool { return r < 33 || r == 127 }) >= 0 {
continue
}
if !seen[id] {
result = append(result, id)
seen[id] = true
}
}
if len(result) > 64 {
result = append(result[:1:1], result[len(result)-63:]...)
}
return result
}
func NormalizeThreadHeader(value string) string {
ids := ThreadMessageIDs(value)
if len(ids) == 0 {
return ""
}
return "<" + strings.Join(ids, "> <") + ">"
}
func ReplyThread(messageID, references, inReplyTo string) (string, string) {
parent := NormalizeThreadHeader("<" + messageID + ">")
if len(ThreadMessageIDs(parent)) != 1 {
return "", ""
}
chain := NormalizeThreadHeader(references)
if chain == "" && len(ThreadMessageIDs(inReplyTo)) == 1 {
chain = NormalizeThreadHeader(inReplyTo)
}
return parent, NormalizeThreadHeader(strings.TrimSpace(chain + " " + parent))
}

View File

@ -165,25 +165,27 @@ func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *im
appendTime = options.Time
}
modelEmail := models.Email{
Type: emailType,
Subject: parsed.Subject,
ReplyTo: storedFields.ReplyTo,
FromName: parsed.From.Name,
FromAddress: parsed.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(parsed.Text), Valid: true},
Html: sql.NullString{String: string(parsed.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
Size: len(rawMessage),
Status: status,
SendUserID: s.ctx.UserID,
SendDate: appendTime,
CreateTime: appendTime,
CronSendTime: appendTime,
MsgID: parsed.MsgID,
Type: emailType,
Subject: parsed.Subject,
ReplyTo: storedFields.ReplyTo,
InReplyTo: parsed.InReplyTo,
ThreadReferences: parsed.ThreadReferences,
FromName: parsed.From.Name,
FromAddress: parsed.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(parsed.Text), Valid: true},
Html: sql.NullString{String: string(parsed.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
Size: len(rawMessage),
Status: status,
SendUserID: s.ctx.UserID,
SendDate: appendTime,
CreateTime: appendTime,
CronSendTime: appendTime,
MsgID: parsed.MsgID,
}
var savedUID imap.UID

View File

@ -102,6 +102,7 @@ func buildEnvelope(email *response.EmailResponseData, traEmail *parsemail.Email)
Cc: usersToAddresses(traEmail.Cc),
Bcc: usersToAddresses(traEmail.Bcc),
MessageID: messageID,
InReplyTo: parsemail.ThreadMessageIDs(email.InReplyTo),
// InReplyTo 和 References 暂不支持
}
}

View File

@ -393,27 +393,29 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
}
modelEmail := models.Email{
Type: cast.ToInt8(emailType),
Subject: email.Subject,
ReplyTo: storedFields.ReplyTo,
FromName: email.From.Name,
FromAddress: email.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(email.Text), Valid: true},
Html: sql.NullString{String: string(email.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
Size: email.Size,
SPFCheck: boolInt8(SPFStatus),
DKIMCheck: boolInt8(dkimStatus),
SendUserID: sendUserID,
SendDate: time.Now(),
Status: cast.ToInt8(email.Status),
CreateTime: time.Now(),
CronSendTime: time.Now(),
MsgID: msgID,
Type: cast.ToInt8(emailType),
Subject: email.Subject,
ReplyTo: storedFields.ReplyTo,
InReplyTo: email.InReplyTo,
ThreadReferences: email.ThreadReferences,
FromName: email.From.Name,
FromAddress: email.From.EmailAddress,
To: storedFields.To,
Bcc: storedFields.Bcc,
Cc: storedFields.Cc,
Text: sql.NullString{String: string(email.Text), Valid: true},
Html: sql.NullString{String: string(email.HTML), Valid: true},
Sender: storedFields.Sender,
Attachments: storedFields.Attachments,
Size: email.Size,
SPFCheck: boolInt8(SPFStatus),
DKIMCheck: boolInt8(dkimStatus),
SendUserID: sendUserID,
SendDate: time.Now(),
Status: cast.ToInt8(email.Status),
CreateTime: time.Now(),
CronSendTime: time.Now(),
MsgID: msgID,
}
var userIDs []int

View File

@ -9,30 +9,33 @@ import (
)
type Email struct {
Id int `xorm:"id pk unsigned int autoincr notnull" json:"id"`
Type int8 `xorm:"type tinyint(4) notnull default(0) comment('邮件类型,0:收到的邮件,1:发送的邮件')" json:"type"`
Subject string `xorm:"subject varchar(1000) notnull default('') comment('邮件标题')" json:"subject"`
ReplyTo string `xorm:"reply_to text comment('回复人')" json:"reply_to"`
FromName string `xorm:"from_name varchar(50) notnull default('') comment('发件人名称')" json:"from_name"`
FromAddress string `xorm:"from_address varchar(100) notnull default('') comment('发件人邮件地址')" json:"from_address"`
To string `xorm:"to text comment('收件人地址')" json:"to"`
Bcc string `xorm:"bcc text comment('密送')" json:"bcc"`
Cc string `xorm:"cc text comment('抄送')" json:"cc"`
Text sql.NullString `xorm:"text text comment('文本内容')" json:"text"`
Html sql.NullString `xorm:"html mediumtext comment('html内容')" json:"html"`
Sender string `xorm:"sender text comment('发送人')" json:"sender"`
Attachments string `xorm:"attachments longtext comment('附件')" json:"attachments"`
SPFCheck int8 `xorm:"spf_check tinyint(1) comment('spf校验是否通过')" json:"spf_check"`
DKIMCheck int8 `xorm:"dkim_check tinyint(1) comment('dkim校验是否通过')" json:"dkim_check"`
Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送,1已发送,2发送失败')" json:"status"` // 0未发送,1已发送,2发送失败
CronSendTime time.Time `xorm:"cron_send_time comment('定时发送时间')" json:"cron_send_time"`
UpdateTime time.Time `xorm:"update_time updated comment('更新时间')" json:"update_time"`
SendUserID int `xorm:"send_user_id unsigned int notnull default(0) comment('发件人用户id')" json:"send_user_id"`
Size int `xorm:"size unsigned int notnull default(1000) comment('邮件大小')" json:"size"`
Error sql.NullString `xorm:"error text comment('投递错误信息')" json:"error"`
SendDate time.Time `xorm:"send_date comment('投递时间')" json:"send_date"`
CreateTime time.Time `xorm:"create_time created" json:"create_time"`
MsgID string `xorm:"msg_id varchar(255) notnull default('') comment('RFC-compliant Message-ID, generated once on creation')" json:"msg_id"`
ReplyEmailID int `xorm:"reply_email_id notnull default(0)" json:"reply_email_id"`
InReplyTo string `xorm:"in_reply_to text" json:"in_reply_to"`
ThreadReferences string `xorm:"thread_references text" json:"thread_references"`
Id int `xorm:"id pk unsigned int autoincr notnull" json:"id"`
Type int8 `xorm:"type tinyint(4) notnull default(0) comment('邮件类型,0:收到的邮件,1:发送的邮件')" json:"type"`
Subject string `xorm:"subject varchar(1000) notnull default('') comment('邮件标题')" json:"subject"`
ReplyTo string `xorm:"reply_to text comment('回复人')" json:"reply_to"`
FromName string `xorm:"from_name varchar(50) notnull default('') comment('发件人名称')" json:"from_name"`
FromAddress string `xorm:"from_address varchar(100) notnull default('') comment('发件人邮件地址')" json:"from_address"`
To string `xorm:"to text comment('收件人地址')" json:"to"`
Bcc string `xorm:"bcc text comment('密送')" json:"bcc"`
Cc string `xorm:"cc text comment('抄送')" json:"cc"`
Text sql.NullString `xorm:"text text comment('文本内容')" json:"text"`
Html sql.NullString `xorm:"html mediumtext comment('html内容')" json:"html"`
Sender string `xorm:"sender text comment('发送人')" json:"sender"`
Attachments string `xorm:"attachments longtext comment('附件')" json:"attachments"`
SPFCheck int8 `xorm:"spf_check tinyint(1) comment('spf校验是否通过')" json:"spf_check"`
DKIMCheck int8 `xorm:"dkim_check tinyint(1) comment('dkim校验是否通过')" json:"dkim_check"`
Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送,1已发送,2发送失败')" json:"status"` // 0未发送,1已发送,2发送失败
CronSendTime time.Time `xorm:"cron_send_time comment('定时发送时间')" json:"cron_send_time"`
UpdateTime time.Time `xorm:"update_time updated comment('更新时间')" json:"update_time"`
SendUserID int `xorm:"send_user_id unsigned int notnull default(0) comment('发件人用户id')" json:"send_user_id"`
Size int `xorm:"size unsigned int notnull default(1000) comment('邮件大小')" json:"size"`
Error sql.NullString `xorm:"error text comment('投递错误信息')" json:"error"`
SendDate time.Time `xorm:"send_date comment('投递时间')" json:"send_date"`
CreateTime time.Time `xorm:"create_time created" json:"create_time"`
MsgID string `xorm:"msg_id varchar(255) notnull default('') comment('RFC-compliant Message-ID, generated once on creation')" json:"msg_id"`
}
func (d *Email) TableName() string {

View File

@ -3,6 +3,7 @@ import {onBeforeRouteLeave, onBeforeRouteUpdate, useRoute} from 'vue-router'
import {ElMessage, ElMessageBox} from 'element-plus'
import {http} from '@/utils/axios'
import lang from '../../i18n/i18n'
import {prepareReply} from './Reply'
export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, editorRef, showCcBcc, isSending, pendingFiles, prepareIdentity}) {
const route = useRoute()
@ -13,6 +14,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
const loadFailed = ref(false)
const sent = ref(false)
const replyTo = ref([])
const replyEmailID = ref(0)
let envelopeSender = null
let originalFrom = ''
let recipientNames = new Map()
@ -24,6 +26,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
const buildMessage = () => ({
draft_id: draftID.value,
draft_version: draftVersion.value,
reply_email_id: replyEmailID.value,
from: {name: ruleForm.nickName, email: `${ruleForm.sender}@${ruleForm.pickedDomain}`},
to: ruleForm.receivers.map(email => ({name: recipientNames.get(email) || '', email})),
cc: ruleForm.cc.map(email => ({name: recipientNames.get(email) || '', email})),
@ -37,6 +40,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
})
const snapshot = () => JSON.stringify({
replyEmailID: replyEmailID.value,
from: [ruleForm.nickName, ruleForm.sender, ruleForm.pickedDomain],
to: ruleForm.receivers, cc: ruleForm.cc, bcc: ruleForm.bcc,
subject: ruleForm.subject, html: valueHtml.value, attrs: fileList
@ -48,7 +52,9 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
isLoading.value = true
loadFailed.value = false
const rawID = route.query.draft
const rawReplyID = route.query.reply
try {
if (rawID !== undefined && rawReplyID !== undefined) throw new Error(lang.fail)
const identity = await prepareIdentity()
if (disposed || sequence !== loadSequence) return
Object.assign(ruleForm, identity)
@ -56,6 +62,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
draftID.value = 0
draftVersion.value = ''
replyTo.value = []
replyEmailID.value = 0
envelopeSender = null
originalFrom = ''
recipientNames = new Map()
@ -67,6 +74,21 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
fileList.splice(0)
showCcBcc.value = false
sent.value = false
if (rawReplyID !== undefined) {
if (typeof rawReplyID !== 'string' || !/^[1-9]\d*$/.test(rawReplyID)) throw new Error(lang.fail)
const id = Number(rawReplyID)
if (!Number.isSafeInteger(id)) throw new Error(lang.fail)
const result = await http.post('/api/v1/email/detail', {id})
if (disposed || sequence !== loadSequence) return
if (result.errorNo !== 0 || Number(result.data?.id) !== id) throw new Error(result.errorMsg || lang.fail)
const reply = prepareReply(result.data, identity)
replyEmailID.value = id
ruleForm.receivers = reply.recipients
ruleForm.pickedDomain = reply.pickedDomain
ruleForm.subject = reply.subject
recipientNames = reply.names
valueHtml.value = reply.html
}
return
}
if (typeof rawID !== 'string' || !/^[1-9]\d*$/.test(rawID)) throw new Error(lang.draft_load_failed)
@ -95,6 +117,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
ruleForm.cc = (message.cc || []).map(item => item.email)
ruleForm.bcc = (message.bcc || []).map(item => item.email)
replyTo.value = message.reply_to || []
replyEmailID.value = message.reply_email_id || 0
ruleForm.subject = message.subject || ''
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>')
@ -151,9 +174,13 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
}
}
onBeforeRouteLeave(confirmLeave)
onBeforeRouteUpdate((to, from) => to.query.draft === from.query.draft ? true : confirmLeave())
watch(() => route.query.draft, value => {
if (String(value || '') !== String(draftID.value || '')) loadDraft()
onBeforeRouteUpdate((to, from) =>
to.query.draft === from.query.draft && to.query.reply === from.query.reply ? true : confirmLeave()
)
watch(() => [route.query.draft, route.query.reply], ([draft, reply], [, previousReply]) => {
if (reply === undefined && String(draft || '') === String(draftID.value || '') &&
(draftID.value > 0 || previousReply === undefined)) return
loadDraft()
})
watch([editorRef, isBusy], ([editor, busy]) => {
if (!editor || disposed) return

View File

@ -0,0 +1,50 @@
import lang from '../../i18n/i18n'
const escapeHTML = value => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const readAddresses = value => {
const addresses = JSON.parse(value || '[]')
if (!Array.isArray(addresses)) throw new Error(lang.fail)
return addresses.filter(item => item && typeof item.EmailAddress === 'string' && item.EmailAddress.trim())
}
const plainBody = message => {
if (message.text) return String(message.text)
const template = document.createElement('template')
template.innerHTML = String(message.html || '')
.replace(/<br\s*\/?>|<\/(?:p|div|li|h[1-6]|tr)>/gi, '\n')
template.content.querySelectorAll('script, style').forEach(node => node.remove())
return template.content.textContent || ''
}
export function prepareReply(message, identity) {
const replyTo = readAddresses(message.reply_to)
const recipients = replyTo.length ? replyTo : [{EmailAddress: message.from_address, Name: message.from_name}]
if (!recipients.length || recipients.some(item => !item.EmailAddress || /[\r\n]/.test(item.EmailAddress))) {
throw new Error(lang.err_email_format)
}
const names = new Map(recipients.map(item => [item.EmailAddress, item.Name || '']))
const subject = String(message.subject || '').trim() || lang.mail_no_subject
const originalRecipients = [...readAddresses(message.to), ...readAddresses(message.cc)]
const pickedDomain = identity.domains.find(domain => originalRecipients.some(item =>
item.EmailAddress.toLowerCase() === `${identity.sender}@${domain}`.toLowerCase()
)) || identity.pickedDomain
const metadata = [
`${lang.sender}: ${message.from_name || ''} <${message.from_address || ''}>`,
`${lang.date}: ${message.send_date || ''}`,
`${lang.title}: ${subject}`
]
const quote = [...metadata, '', plainBody(message)].join('\n')
return {
recipients: [...names.keys()],
names,
pickedDomain,
subject: /^re\s*:/i.test(subject) ? subject : `Re: ${subject}`,
html: `<p><br></p><blockquote><p>${escapeHTML(quote).replace(/\r?\n/g, '<br>')}</p></blockquote>`
}
}

View File

@ -91,6 +91,11 @@ export function usePage() {
})
const backToList = () => router.push({name: 'list'})
const handleReply = () => {
const id = Number(detailData.value.id)
if (loading.value || loadError.value || !Number.isSafeInteger(id) || id <= 0) return
return router.push({name: 'editer', query: {reply: String(id)}})
}
const getInitial = (name) => {
if (!name) return '?';
@ -164,6 +169,7 @@ export function usePage() {
loadError,
loadDetail,
backToList,
handleReply,
getInitial,
formatDetailDate,
handleDelete