From 23c2fbbdd54d0f4cac4f1ba6820069cdf90dba5c Mon Sep 17 00:00:00 2001 From: Cd1s <135119404+Cd1s@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:43:59 +0700 Subject: [PATCH] fix: generate Message-ID once and persist to DB (#328) Previously, a random suffix was generated every time BuildBytes or ForwardBuildBytes was called, causing IMAP/POP3 clients to treat the same message as a new message on every sync and breaking conversation threading. Changes: - Add msg_id column to the email table (synced automatically via Sync2) - Generate a crypto/rand-based RFC-compliant Message-ID once at email creation time (send.go for outgoing, read_content.go for incoming) - Preserve the original Message-ID from externally received emails - Load the persisted MsgID when rebuilding Email from DB records - Use the stored MsgID in BuildBytes, ForwardBuildBytes and the IMAP ENVELOPE response; fall back to the old numeric format for legacy rows Fixes the MSGID_SHORT SpamAssassin penalty and Gmail rejection while keeping Message-IDs stable across syncs. Co-authored-by: root Co-authored-by: Claude Sonnet 4.6 --- server/controllers/email/send.go | 2 ++ server/dto/parsemail/email.go | 27 ++++++++++++++++++++-- server/listen/imap_server/session_fetch.go | 9 ++++++-- server/listen/smtp_server/read_content.go | 7 ++++++ server/models/email.go | 1 + 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/server/controllers/email/send.go b/server/controllers/email/send.go index ebfc4ae..2321143 100644 --- a/server/controllers/email/send.go +++ b/server/controllers/email/send.go @@ -191,6 +191,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { CronSendTime: time.Now(), Status: 1, CreateTime: time.Now(), + MsgID: parsemail.GenerateMsgID(config.Instance.Domain), } _, err = db.Instance.Insert(&modelEmail) @@ -202,6 +203,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } e.MessageId = cast.ToInt64(modelEmail.Id) + e.MsgID = modelEmail.MsgID async.New(ctx).Process(func(p any) { errMsg := "" diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index d72db03..5eeb465 100644 --- a/server/dto/parsemail/email.go +++ b/server/dto/parsemail/email.go @@ -2,6 +2,7 @@ package parsemail import ( "bytes" + "crypto/rand" "encoding/base64" "encoding/json" "fmt" @@ -68,9 +69,18 @@ type Email struct { Date string Status int // 0未发送,1已发送,2发送失败,3删除,5广告邮件 MessageId int64 + MsgID string // RFC-compliant Message-ID, persisted in DB Size int } +// GenerateMsgID creates an RFC-compliant Message-ID unique enough to avoid spam filters. +// It must be called once at email creation time and the result persisted to DB. +func GenerateMsgID(domain string) string { + randBytes := make([]byte, 16) + _, _ = rand.Read(randBytes) + return fmt.Sprintf("%x.%d@%s", randBytes, time.Now().UnixNano(), domain) +} + // Xss filter policy var ( strictPolicy *bluemonday.Policy @@ -193,6 +203,7 @@ func NewEmailFromModel(d models.Email) *Email { return &Email{ MessageId: cast.ToInt64(d.Id), + MsgID: d.MsgID, From: &User{ Name: d.FromName, EmailAddress: d.FromAddress, @@ -218,6 +229,10 @@ func NewEmailFromReader(to []string, r io.Reader, size int) *Email { } ret.Size = size + // 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), ">"), "<") + } ret.From = buildUser(m.Header.Get("From")) smtpTo := buildUsers(to) @@ -402,7 +417,11 @@ func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User) []b h.SetAddressList("Sender", senderAddress) h.SetAddressList("To", to) h.SetText("Subject", e.Subject) - h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, config.Instance.Domain)) + if e.MsgID != "" { + h.SetMessageID(e.MsgID) + } else { + h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, config.Instance.Domain)) + } if len(e.Cc) != 0 { cc := []*mail.Address{} for _, user := range e.Cc { @@ -553,7 +572,11 @@ func (e *Email) BuildBytes(ctx *context.Context, dkim bool) []byte { } else { h.SetDate(time.Now()) } - h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, config.Instance.Domain)) + if e.MsgID != "" { + h.SetMessageID(e.MsgID) + } else { + h.SetMessageID(fmt.Sprintf("%d@%s", e.MessageId, config.Instance.Domain)) + } h.SetAddressList("From", from) h.SetAddressList("Sender", from) h.SetAddressList("To", to) diff --git a/server/listen/imap_server/session_fetch.go b/server/listen/imap_server/session_fetch.go index 56a5ce1..5d17893 100644 --- a/server/listen/imap_server/session_fetch.go +++ b/server/listen/imap_server/session_fetch.go @@ -88,8 +88,13 @@ func buildEnvelope(email *response.EmailResponseData, traEmail *parsemail.Email) replyTo = from } - // Message-ID - messageID := fmt.Sprintf("<%d@%s>", email.Id, config.Instance.Domain) + // Message-ID: use the persisted value so IMAP clients see a stable ID on every sync. + var messageID string + if email.MsgID != "" { + messageID = fmt.Sprintf("<%s>", email.MsgID) + } else { + messageID = fmt.Sprintf("<%d@%s>", email.Id, config.Instance.Domain) + } return &imap.Envelope{ Date: email.CreateTime, diff --git a/server/listen/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go index 6bb6e98..4a24a51 100644 --- a/server/listen/smtp_server/read_content.go +++ b/server/listen/smtp_server/read_content.go @@ -221,6 +221,11 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI return nil, nil, nil } + msgID := email.MsgID + if msgID == "" { + msgID = parsemail.GenerateMsgID(config.Instance.Domain) + } + modelEmail := models.Email{ Type: cast.ToInt8(emailType), Subject: email.Subject, @@ -242,6 +247,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI Status: cast.ToInt8(email.Status), CreateTime: time.Now(), CronSendTime: time.Now(), + MsgID: msgID, } _, err := db.Instance.Insert(&modelEmail) @@ -252,6 +258,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI if modelEmail.Id > 0 { email.MessageId = cast.ToInt64(modelEmail.Id) + email.MsgID = modelEmail.MsgID } // 收信人信息 var users []*models.User diff --git a/server/models/email.go b/server/models/email.go index a428e65..ac1ad32 100644 --- a/server/models/email.go +++ b/server/models/email.go @@ -30,6 +30,7 @@ type Email struct { 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 {