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)"
|
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
|
||||||
|
|||||||
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 {
|
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)
|
||||||
|
|||||||
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()),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@ -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 暂不支持
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\r?\n/g, '<br>')
|
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>')
|
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
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user