Merge pull request 'fix(imap): FETCH BODY/BODYSTRUCTURE 解析失败时服务器 panic,客户端只能取到部分邮件' (#10) from dsh/mailgo:fix/imap-fetch-body-panic into main

Reviewed-on: kevin/mailgo#10
This commit is contained in:
2026-08-19 08:55:22 -04:00
2 changed files with 122 additions and 1 deletions
+29
View File
@@ -461,6 +461,15 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [
if err == nil { if err == nil {
imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure) imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
} }
// 防御:FetchBodyStructure 对部分合法/畸形 MIME 会失败并返回
// nil(典型:message/rfc822 附件为 base64 编码时库内不解码
// 直接按嵌套消息解析头;或 multipart 边界截断)。BodyStructure
// 为 nil 时 go-imap 格式化 FETCH 响应会在 send() 协程 panic
// (nil 指针解引用),连接中断导致客户端只收到部分邮件甚至
// 一直卡在同步。解析失败时降级为 text/plain 单段结构。
if imapMsg.BodyStructure == nil {
imapMsg.BodyStructure = fallbackBodyStructure(rawMsg)
}
default: default:
section, err := imap.ParseBodySectionName(item) section, err := imap.ParseBodySectionName(item)
if err != nil { if err != nil {
@@ -471,13 +480,33 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [
return nil, err return nil, err
} }
literal, _ := backendutil.FetchBodySection(hdr, body, section) literal, _ := backendutil.FetchBodySection(hdr, body, section)
if literal != nil {
imapMsg.Body[section] = literal imapMsg.Body[section] = literal
} }
} }
}
return imapMsg, nil return imapMsg, nil
} }
// fallbackBodyStructure 构造一个 text/plain 单段 BodyStructure,用于
// MIME 解析失败的消息(保证 FETCH BODY/BODYSTRUCTURE 不因 nil 崩溃)。
func fallbackBodyStructure(raw []byte) *imap.BodyStructure {
size := uint32(len(raw))
lines := uint32(bytes.Count(raw, []byte{'\n'}))
if len(raw) > 0 && raw[len(raw)-1] != '\n' {
lines++
}
return &imap.BodyStructure{
MIMEType: "text",
MIMESubType: "plain",
Params: map[string]string{"charset": "utf-8"},
Encoding: "8bit",
Size: size,
Lines: lines,
}
}
func messageRawData(msg *db.Message) []byte { func messageRawData(msg *db.Message) []byte {
if msg.RawData != "" { if msg.RawData != "" {
return []byte(msg.RawData) return []byte(msg.RawData)
+92
View File
@@ -188,3 +188,95 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) {
// 客户端意图是标记最新一封(ids[2])为已读 // 客户端意图是标记最新一封(ids[2])为已读
assertReadState(t, stores, ids[2], true) assertReadState(t, stores, ids[2], true)
} }
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
// 不得因 nil BodyStructure 触发服务器 panic(否则连接中断,客户端只收到
// 部分邮件或一直卡在同步)。修复前 go-imap send() 协程会 nil 指针崩溃。
func TestFetchBodyMalformedMIME(t *testing.T) {
stores, addr := startIntegrationServer(t)
// 1) base64 编码的 message/rfc822 附件(转发邮件场景):
// backendutil.FetchBodyStructure 不解码 base64,直接把编码文本
// 当嵌套消息头解析 → "malformed MIME header line" 错误。
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
msgWithRFC822 := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "alice@example.com",
ToAddr: "alice@example.com",
Subject: "fwd",
Date: time.Now().Add(-2 * time.Hour),
RawData: "From: alice@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: fwd\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"==fwd==\"\r\n\r\n" +
"--==fwd==\r\n" +
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
"正文\r\n\r\n" +
"--==fwd==\r\n" +
"Content-Type: message/rfc822\r\n" +
"Content-Transfer-Encoding: base64\r\n" +
"Content-Disposition: attachment; filename=\"original.eml\"\r\n" +
"MIME-Version: 1.0\r\n\r\n" +
rfc822Body + "\r\n" +
"--==fwd==--\r\n",
}
// 2) 截断的 multipart(缺少结束边界):BODYSTRUCTURE(extended) 解析报错
msgTruncated := &db.Message{
UserID: 1,
Folder: "INBOX",
FromAddr: "alice@example.com",
ToAddr: "alice@example.com",
Subject: "truncated",
Date: time.Now().Add(-1 * time.Hour),
RawData: "From: alice@example.com\r\n" +
"To: alice@example.com\r\n" +
"Subject: truncated\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/alternative; boundary=\"==trunc==\"\r\n\r\n" +
"--==trunc==\r\n" +
"Content-Type: text/plain\r\n\r\n" +
"hello\r\n",
// 无结束边界
}
if err := stores.Mails.Create(msgWithRFC822); err != nil {
t.Fatalf("create msg: %v", err)
}
if err := stores.Mails.Create(msgTruncated); err != nil {
t.Fatalf("create msg: %v", err)
}
c := loginAndSelect(t, addr)
seqset := new(imap.SeqSet)
seqset.AddRange(1, 2)
// BODY:历史上 message/rfc822 消息解析失败 → nil BodyStructure → panic
msgs := make(chan *imap.Message, 10)
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBody}, msgs); err != nil {
t.Fatalf("fetch body: %v", err)
}
got := 0
for range msgs {
got++
}
if got != 2 {
t.Fatalf("FETCH BODY 返回 %d/2 封", got)
}
// BODYSTRUCTURE:截断 multipart 在 extended 解析时报错 → nil → panic
msgs2 := make(chan *imap.Message, 10)
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBodyStructure}, msgs2); err != nil {
t.Fatalf("fetch bodystructure: %v", err)
}
got2 := 0
for range msgs2 {
got2++
}
if got2 != 2 {
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", got2)
}
}