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}

` : '


') @@ -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 diff --git a/frontend/src/views/Editer/Reply.js b/frontend/src/views/Editer/Reply.js new file mode 100644 index 0000000..6bc963a --- /dev/null +++ b/frontend/src/views/Editer/Reply.js @@ -0,0 +1,50 @@ +import lang from '../../i18n/i18n' + +const escapeHTML = value => String(value ?? '') + .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(/|<\/(?: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: `


${escapeHTML(quote).replace(/\r?\n/g, '
')}

` + } +} 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