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 <root@Claw-ai.tail1478c.ts.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cd1s 2026-04-13 10:43:59 +07:00 committed by GitHub
parent 826ac26636
commit 23c2fbbdd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 42 additions and 4 deletions

View File

@ -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 := ""

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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 {