diff --git a/backend/controllers/email/draft.go b/backend/controllers/email/draft.go
index 719c631..514e807 100644
--- a/backend/controllers/email/draft.go
+++ b/backend/controllers/email/draft.go
@@ -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
diff --git a/backend/controllers/email/reply.go b/backend/controllers/email/reply.go
new file mode 100644
index 0000000..c22c262
--- /dev/null
+++ b/backend/controllers/email/reply.go
@@ -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
+}
diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go
index 5142585..41e483c 100644
--- a/backend/controllers/email/send.go
+++ b/backend/controllers/email/send.go
@@ -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)
diff --git a/backend/db/migration/email_thread.go b/backend/db/migration/email_thread.go
new file mode 100644
index 0000000..6d5b463
--- /dev/null
+++ b/backend/db/migration/email_thread.go
@@ -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
+}
diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go
index 72f2c4f..5faa40d 100644
--- a/backend/db/migration/registry.go
+++ b/backend/db/migration/registry.go
@@ -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),
}
}
diff --git a/backend/dto/parsemail/email.go b/backend/dto/parsemail/email.go
index a0b500a..9f6aa33 100644
--- a/backend/dto/parsemail/email.go
+++ b/backend/dto/parsemail/email.go
@@ -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)
diff --git a/backend/dto/parsemail/thread.go b/backend/dto/parsemail/thread.go
new file mode 100644
index 0000000..05d2d30
--- /dev/null
+++ b/backend/dto/parsemail/thread.go
@@ -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))
+}
diff --git a/backend/listen/imap_server/server.go b/backend/listen/imap_server/server.go
index 76c3e52..40cad73 100644
--- a/backend/listen/imap_server/server.go
+++ b/backend/listen/imap_server/server.go
@@ -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
diff --git a/backend/listen/imap_server/session_fetch.go b/backend/listen/imap_server/session_fetch.go
index f00e705..1d1befc 100644
--- a/backend/listen/imap_server/session_fetch.go
+++ b/backend/listen/imap_server/session_fetch.go
@@ -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 暂不支持
}
}
diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go
index 55226cc..412b559 100644
--- a/backend/listen/smtp_server/read_content.go
+++ b/backend/listen/smtp_server/read_content.go
@@ -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
diff --git a/backend/models/email.go b/backend/models/email.go
index 9c27cec..7804895 100644
--- a/backend/models/email.go
+++ b/backend/models/email.go
@@ -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 {
diff --git a/frontend/src/views/Editer/Draft.js b/frontend/src/views/Editer/Draft.js
index 01cce74..1e4b165 100644
--- a/frontend/src/views/Editer/Draft.js
+++ b/frontend/src/views/Editer/Draft.js
@@ -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(/\r?\n/g, '
')
valueHtml.value = message.html || (plainText ? `
${plainText}
` : '` + } +} diff --git a/frontend/src/views/EmailDetail/EmailDetail.js b/frontend/src/views/EmailDetail/EmailDetail.js index f8ebffd..29794f8 100644 --- a/frontend/src/views/EmailDetail/EmailDetail.js +++ b/frontend/src/views/EmailDetail/EmailDetail.js @@ -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${escapeHTML(quote).replace(/\r?\n/g, '
')}