保存邮件回复链路接入界面前的开发检查点
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)" const ownedDraftRelation = "EXISTS (SELECT 1 FROM user_email WHERE user_id = ? AND email_id = ? AND status = ? AND is_deleted = 0)"
var draftColumns = []string{ var draftColumns = []string{
"reply_email_id", "in_reply_to", "thread_references",
"subject", "reply_to", "from_name", "from_address", "to", "bcc", "cc", "subject", "reply_to", "from_name", "from_address", "to", "bcc", "cc",
"text", "html", "sender", "attachments", "spf_check", "dkim_check", "text", "html", "sender", "attachments", "spf_check", "dkim_check",
"status", "send_date", "cron_send_time", "msg_id", "size", "error", "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)), Text: []byte(parsemail.SanitizeText(input.Text)), HTML: []byte(parsemail.SanitizeHTML(input.HTML)),
} }
total := 0 total := 0
if err := applyReplyThread(ctx, input, message); err != nil {
return nil, err
}
for _, item := range input.Attachments { for _, item := range input.Attachments {
contentType, content, err := decodeAttachment(item.Data) contentType, content, err := decodeAttachment(item.Data)
if err != nil { if err != nil {
@ -149,6 +153,7 @@ func buildDraft(ctx *context.Context, input sendRequest) (*models.Email, error)
now := time.Now() now := time.Now()
return &models.Email{ return &models.Email{
Type: consts.EmailTypeSend, Status: consts.EmailStatusDrafts, SendUserID: ctx.UserID, 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, 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, ReplyTo: fields.ReplyTo, To: fields.To, Cc: fields.Cc, Bcc: fields.Bcc, Sender: fields.Sender,
Text: sql.NullString{String: string(message.Text), Valid: true}, 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) { func draftMessage(draft models.Email) (sendRequest, error) {
message := sendRequest{ message := sendRequest{
DraftID: draft.Id, DraftVersion: draft.MsgID, DraftID: draft.Id, DraftVersion: draft.MsgID,
From: user{Name: draft.FromName, Email: draft.FromAddress}, ReplyEmailID: draft.ReplyEmailID,
Subject: draft.Subject, Text: draft.Text.String, HTML: draft.Html.String, From: user{Name: draft.FromName, Email: draft.FromAddress},
Subject: draft.Subject, Text: draft.Text.String, HTML: draft.Html.String,
Attachments: []attachment{}, Attachments: []attachment{},
} }
var sender *parsemail.User 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 { type sendRequest struct {
ReplyEmailID int `json:"reply_email_id"`
DraftID int `json:"draft_id"` DraftID int `json:"draft_id"`
DraftVersion string `json:"draft_version"` DraftVersion string `json:"draft_version"`
ReplyTo []user `json:"reply_to"` ReplyTo []user `json:"reply_to"`
@ -122,6 +123,11 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
} }
e := &parsemail.Email{} 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 { for _, to := range reqData.To {
e.To = append(e.To, &parsemail.User{ e.To = append(e.To, &parsemail.User{
@ -216,6 +222,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return return
} }
e.InReplyTo, e.ThreadReferences = inReplyTo, threadReferences
msgID, err := parsemail.GenerateMsgID(domains.Primary) msgID, err := parsemail.GenerateMsgID(domains.Primary)
if err != nil { if err != nil {
log.WithContext(ctx).WithError(err).Error("generate outgoing message ID failed") 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) messageSize += len(item.Content)
} }
modelEmail := models.Email{ modelEmail := models.Email{
Type: consts.EmailTypeSend, Type: consts.EmailTypeSend,
Subject: e.Subject, Subject: e.Subject,
ReplyTo: storedFields.ReplyTo, ReplyTo: storedFields.ReplyTo,
FromName: e.From.Name, ReplyEmailID: reqData.ReplyEmailID,
FromAddress: e.From.EmailAddress, InReplyTo: e.InReplyTo,
To: storedFields.To, ThreadReferences: e.ThreadReferences,
Bcc: storedFields.Bcc, FromName: e.From.Name,
Cc: storedFields.Cc, FromAddress: e.From.EmailAddress,
Text: sql.NullString{String: string(e.Text), Valid: true}, To: storedFields.To,
Html: sql.NullString{String: string(e.HTML), Valid: true}, Bcc: storedFields.Bcc,
Sender: storedFields.Sender, Cc: storedFields.Cc,
Attachments: storedFields.Attachments, Text: sql.NullString{String: string(e.Text), Valid: true},
SPFCheck: 1, Html: sql.NullString{String: string(e.HTML), Valid: true},
DKIMCheck: 1, Sender: storedFields.Sender,
SendUserID: ctx.UserID, Attachments: storedFields.Attachments,
SendDate: now, SPFCheck: 1,
CronSendTime: now, DKIMCheck: 1,
Status: consts.EmailStatusDeliveryPending, SendUserID: ctx.UserID,
CreateTime: now, SendDate: now,
MsgID: msgID, CronSendTime: now,
Size: messageSize, Status: consts.EmailStatusDeliveryPending,
CreateTime: now,
MsgID: msgID,
Size: messageSize,
} }
err = persistOutgoingEmailWithDraft(db.Instance, ctx.UserID, &modelEmail, reqData.DraftID, reqData.DraftVersion) 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()), DefineForDialects(26, "create_account_groups", accountGroupStatements()),
DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema), DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema),
DefineCustom(28, "expand_recovery_code_ciphertext", "expand_recovery_code_ciphertext_v1", applyRecoveryCodeCiphertextSchema), 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 // Email is the type used for email messages
type Email struct { type Email struct {
EnvelopeFrom string InReplyTo string
ReplyTo []*User ThreadReferences string
From *User EnvelopeFrom string
To []*User ReplyTo []*User
Bcc []*User From *User
Cc []*User To []*User
Subject string Bcc []*User
Text []byte // Plaintext message (optional) Cc []*User
HTML []byte // Html message (optional) Subject string
Sender *User // override From as SMTP envelope sender (optional) Text []byte // Plaintext message (optional)
Headers textproto.MIMEHeader HTML []byte // Html message (optional)
Attachments []*Attachment Sender *User // override From as SMTP envelope sender (optional)
ReadReceipt []string Headers textproto.MIMEHeader
Date string Attachments []*Attachment
Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件 ReadReceipt []string
MessageId int64 Date string
MsgID string // RFC-compliant Message-ID, persisted in DB Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件
Size int MessageId int64
Authentication *EmailAuthentication MsgID string // RFC-compliant Message-ID, persisted in DB
Size int
Authentication *EmailAuthentication
} }
func (e *Email) SenderFilterAddresses() []string { func (e *Email) SenderFilterAddresses() []string {
@ -344,8 +346,10 @@ func NewEmailFromModel(d models.Email) (*Email, error) {
} }
ret := &Email{ ret := &Email{
MessageId: cast.ToInt64(d.Id), MessageId: cast.ToInt64(d.Id),
MsgID: d.MsgID, InReplyTo: NormalizeThreadHeader(d.InReplyTo),
ThreadReferences: NormalizeThreadHeader(d.ThreadReferences),
MsgID: d.MsgID,
From: &User{ From: &User{
Name: d.FromName, Name: d.FromName,
EmailAddress: d.FromAddress, EmailAddress: d.FromAddress,
@ -387,6 +391,8 @@ func NewEmailFromReader(to []string, r io.Reader, size int) (*Email, error) {
} }
ret.Size = size 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. // Preserve the original Message-ID from the sender so it is stored and reused consistently.
if mid := m.Header.Get("Message-Id"); mid != "" { if mid := m.Header.Get("Message-Id"); mid != "" {
ret.MsgID = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(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.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, cfg.Domain))
} }
h.SetAddressList("From", from) h.SetAddressList("From", from)
h.SetMsgIDList("In-Reply-To", ThreadMessageIDs(e.InReplyTo))
h.SetMsgIDList("References", ThreadMessageIDs(e.ThreadReferences))
h.SetAddressList("Sender", from) h.SetAddressList("Sender", from)
h.SetAddressList("To", to) h.SetAddressList("To", to)
h.SetText("Subject", e.Subject) 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 appendTime = options.Time
} }
modelEmail := models.Email{ modelEmail := models.Email{
Type: emailType, Type: emailType,
Subject: parsed.Subject, Subject: parsed.Subject,
ReplyTo: storedFields.ReplyTo, ReplyTo: storedFields.ReplyTo,
FromName: parsed.From.Name, InReplyTo: parsed.InReplyTo,
FromAddress: parsed.From.EmailAddress, ThreadReferences: parsed.ThreadReferences,
To: storedFields.To, FromName: parsed.From.Name,
Bcc: storedFields.Bcc, FromAddress: parsed.From.EmailAddress,
Cc: storedFields.Cc, To: storedFields.To,
Text: sql.NullString{String: string(parsed.Text), Valid: true}, Bcc: storedFields.Bcc,
Html: sql.NullString{String: string(parsed.HTML), Valid: true}, Cc: storedFields.Cc,
Sender: storedFields.Sender, Text: sql.NullString{String: string(parsed.Text), Valid: true},
Attachments: storedFields.Attachments, Html: sql.NullString{String: string(parsed.HTML), Valid: true},
Size: len(rawMessage), Sender: storedFields.Sender,
Status: status, Attachments: storedFields.Attachments,
SendUserID: s.ctx.UserID, Size: len(rawMessage),
SendDate: appendTime, Status: status,
CreateTime: appendTime, SendUserID: s.ctx.UserID,
CronSendTime: appendTime, SendDate: appendTime,
MsgID: parsed.MsgID, CreateTime: appendTime,
CronSendTime: appendTime,
MsgID: parsed.MsgID,
} }
var savedUID imap.UID var savedUID imap.UID

View File

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

View File

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

View File

@ -9,30 +9,33 @@ import (
) )
type Email struct { type Email struct {
Id int `xorm:"id pk unsigned int autoincr notnull" json:"id"` ReplyEmailID int `xorm:"reply_email_id notnull default(0)" json:"reply_email_id"`
Type int8 `xorm:"type tinyint(4) notnull default(0) comment('邮件类型,0:收到的邮件,1:发送的邮件')" json:"type"` InReplyTo string `xorm:"in_reply_to text" json:"in_reply_to"`
Subject string `xorm:"subject varchar(1000) notnull default('') comment('邮件标题')" json:"subject"` ThreadReferences string `xorm:"thread_references text" json:"thread_references"`
ReplyTo string `xorm:"reply_to text comment('回复人')" json:"reply_to"` Id int `xorm:"id pk unsigned int autoincr notnull" json:"id"`
FromName string `xorm:"from_name varchar(50) notnull default('') comment('发件人名称')" json:"from_name"` Type int8 `xorm:"type tinyint(4) notnull default(0) comment('邮件类型,0:收到的邮件,1:发送的邮件')" json:"type"`
FromAddress string `xorm:"from_address varchar(100) notnull default('') comment('发件人邮件地址')" json:"from_address"` Subject string `xorm:"subject varchar(1000) notnull default('') comment('邮件标题')" json:"subject"`
To string `xorm:"to text comment('收件人地址')" json:"to"` ReplyTo string `xorm:"reply_to text comment('回复人')" json:"reply_to"`
Bcc string `xorm:"bcc text comment('密送')" json:"bcc"` FromName string `xorm:"from_name varchar(50) notnull default('') comment('发件人名称')" json:"from_name"`
Cc string `xorm:"cc text comment('抄送')" json:"cc"` FromAddress string `xorm:"from_address varchar(100) notnull default('') comment('发件人邮件地址')" json:"from_address"`
Text sql.NullString `xorm:"text text comment('文本内容')" json:"text"` To string `xorm:"to text comment('收件人地址')" json:"to"`
Html sql.NullString `xorm:"html mediumtext comment('html内容')" json:"html"` Bcc string `xorm:"bcc text comment('密送')" json:"bcc"`
Sender string `xorm:"sender text comment('发送人')" json:"sender"` Cc string `xorm:"cc text comment('抄送')" json:"cc"`
Attachments string `xorm:"attachments longtext comment('附件')" json:"attachments"` Text sql.NullString `xorm:"text text comment('文本内容')" json:"text"`
SPFCheck int8 `xorm:"spf_check tinyint(1) comment('spf校验是否通过')" json:"spf_check"` Html sql.NullString `xorm:"html mediumtext comment('html内容')" json:"html"`
DKIMCheck int8 `xorm:"dkim_check tinyint(1) comment('dkim校验是否通过')" json:"dkim_check"` Sender string `xorm:"sender text comment('发送人')" json:"sender"`
Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送,1已发送,2发送失败')" json:"status"` // 0未发送,1已发送,2发送失败 Attachments string `xorm:"attachments longtext comment('附件')" json:"attachments"`
CronSendTime time.Time `xorm:"cron_send_time comment('定时发送时间')" json:"cron_send_time"` SPFCheck int8 `xorm:"spf_check tinyint(1) comment('spf校验是否通过')" json:"spf_check"`
UpdateTime time.Time `xorm:"update_time updated comment('更新时间')" json:"update_time"` DKIMCheck int8 `xorm:"dkim_check tinyint(1) comment('dkim校验是否通过')" json:"dkim_check"`
SendUserID int `xorm:"send_user_id unsigned int notnull default(0) comment('发件人用户id')" json:"send_user_id"` Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送,1已发送,2发送失败')" json:"status"` // 0未发送,1已发送,2发送失败
Size int `xorm:"size unsigned int notnull default(1000) comment('邮件大小')" json:"size"` CronSendTime time.Time `xorm:"cron_send_time comment('定时发送时间')" json:"cron_send_time"`
Error sql.NullString `xorm:"error text comment('投递错误信息')" json:"error"` UpdateTime time.Time `xorm:"update_time updated comment('更新时间')" json:"update_time"`
SendDate time.Time `xorm:"send_date comment('投递时间')" json:"send_date"` SendUserID int `xorm:"send_user_id unsigned int notnull default(0) comment('发件人用户id')" json:"send_user_id"`
CreateTime time.Time `xorm:"create_time created" json:"create_time"` Size int `xorm:"size unsigned int notnull default(1000) comment('邮件大小')" json:"size"`
MsgID string `xorm:"msg_id varchar(255) notnull default('') comment('RFC-compliant Message-ID, generated once on creation')" json:"msg_id"` 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 { 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 {ElMessage, ElMessageBox} from 'element-plus'
import {http} from '@/utils/axios' import {http} from '@/utils/axios'
import lang from '../../i18n/i18n' import lang from '../../i18n/i18n'
import {prepareReply} from './Reply'
export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, editorRef, showCcBcc, isSending, pendingFiles, prepareIdentity}) { export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, editorRef, showCcBcc, isSending, pendingFiles, prepareIdentity}) {
const route = useRoute() const route = useRoute()
@ -13,6 +14,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
const loadFailed = ref(false) const loadFailed = ref(false)
const sent = ref(false) const sent = ref(false)
const replyTo = ref([]) const replyTo = ref([])
const replyEmailID = ref(0)
let envelopeSender = null let envelopeSender = null
let originalFrom = '' let originalFrom = ''
let recipientNames = new Map() let recipientNames = new Map()
@ -24,6 +26,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
const buildMessage = () => ({ const buildMessage = () => ({
draft_id: draftID.value, draft_id: draftID.value,
draft_version: draftVersion.value, draft_version: draftVersion.value,
reply_email_id: replyEmailID.value,
from: {name: ruleForm.nickName, email: `${ruleForm.sender}@${ruleForm.pickedDomain}`}, from: {name: ruleForm.nickName, email: `${ruleForm.sender}@${ruleForm.pickedDomain}`},
to: ruleForm.receivers.map(email => ({name: recipientNames.get(email) || '', email})), to: ruleForm.receivers.map(email => ({name: recipientNames.get(email) || '', email})),
cc: ruleForm.cc.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({ const snapshot = () => JSON.stringify({
replyEmailID: replyEmailID.value,
from: [ruleForm.nickName, ruleForm.sender, ruleForm.pickedDomain], from: [ruleForm.nickName, ruleForm.sender, ruleForm.pickedDomain],
to: ruleForm.receivers, cc: ruleForm.cc, bcc: ruleForm.bcc, to: ruleForm.receivers, cc: ruleForm.cc, bcc: ruleForm.bcc,
subject: ruleForm.subject, html: valueHtml.value, attrs: fileList subject: ruleForm.subject, html: valueHtml.value, attrs: fileList
@ -48,7 +52,9 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
isLoading.value = true isLoading.value = true
loadFailed.value = false loadFailed.value = false
const rawID = route.query.draft const rawID = route.query.draft
const rawReplyID = route.query.reply
try { try {
if (rawID !== undefined && rawReplyID !== undefined) throw new Error(lang.fail)
const identity = await prepareIdentity() const identity = await prepareIdentity()
if (disposed || sequence !== loadSequence) return if (disposed || sequence !== loadSequence) return
Object.assign(ruleForm, identity) Object.assign(ruleForm, identity)
@ -56,6 +62,7 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
draftID.value = 0 draftID.value = 0
draftVersion.value = '' draftVersion.value = ''
replyTo.value = [] replyTo.value = []
replyEmailID.value = 0
envelopeSender = null envelopeSender = null
originalFrom = '' originalFrom = ''
recipientNames = new Map() recipientNames = new Map()
@ -67,6 +74,21 @@ export function useDraft({router, groupStore, ruleForm, valueHtml, fileList, edi
fileList.splice(0) fileList.splice(0)
showCcBcc.value = false showCcBcc.value = false
sent.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 return
} }
if (typeof rawID !== 'string' || !/^[1-9]\d*$/.test(rawID)) throw new Error(lang.draft_load_failed) 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.cc = (message.cc || []).map(item => item.email)
ruleForm.bcc = (message.bcc || []).map(item => item.email) ruleForm.bcc = (message.bcc || []).map(item => item.email)
replyTo.value = message.reply_to || [] replyTo.value = message.reply_to || []
replyEmailID.value = message.reply_email_id || 0
ruleForm.subject = message.subject || '' ruleForm.subject = message.subject || ''
const plainText = (message.text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\r?\n/g, '<br>') 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>') 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) onBeforeRouteLeave(confirmLeave)
onBeforeRouteUpdate((to, from) => to.query.draft === from.query.draft ? true : confirmLeave()) onBeforeRouteUpdate((to, from) =>
watch(() => route.query.draft, value => { to.query.draft === from.query.draft && to.query.reply === from.query.reply ? true : confirmLeave()
if (String(value || '') !== String(draftID.value || '')) loadDraft() )
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]) => { watch([editorRef, isBusy], ([editor, busy]) => {
if (!editor || disposed) return 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 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) => { const getInitial = (name) => {
if (!name) return '?'; if (!name) return '?';
@ -164,6 +169,7 @@ export function usePage() {
loadError, loadError,
loadDetail, loadDetail,
backToList, backToList,
handleReply,
getInitial, getInitial,
formatDetailDate, formatDetailDate,
handleDelete handleDelete