This commit is contained in:
parent
e1c44bbe33
commit
d5073cccbe
@ -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
|
||||
|
||||
50
backend/controllers/email/reply.go
Normal file
50
backend/controllers/email/reply.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
27
backend/db/migration/email_thread.go
Normal file
27
backend/db/migration/email_thread.go
Normal 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
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
57
backend/dto/parsemail/thread.go
Normal file
57
backend/dto/parsemail/thread.go
Normal 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))
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 暂不支持
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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
|
||||
|
||||
50
frontend/src/views/Editer/Reply.js
Normal file
50
frontend/src/views/Editer/Reply.js
Normal file
@ -0,0 +1,50 @@
|
||||
import lang from '../../i18n/i18n'
|
||||
|
||||
const escapeHTML = value => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
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>`
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user