From 3a4636c2c54e7626a9f415e616677606da4773f5 Mon Sep 17 00:00:00 2001 From: dsh Date: Wed, 19 Aug 2026 08:52:39 -0400 Subject: [PATCH] =?UTF-8?q?fix(imap):=20FETCH=20BODY/BODYSTRUCTURE=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E6=97=B6=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=20panic=20=E5=AF=BC=E8=87=B4=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=8F=AA=E8=83=BD=E5=8F=96=E5=88=B0=E9=83=A8=E5=88=86?= =?UTF-8?q?=E9=82=AE=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:backendutil.FetchBodyStructure 对部分消息返回 nil(典型场景: - message/rfc822 附件为 base64 编码时库内不解码,把编码文本当嵌套 消息头解析报错(转发邮件场景,如 .mail-monitor/forward.py 转发) - multipart 缺少结束边界(截断)时 extended 解析报错) buildIMAPMessage 未处理 nil,go-imap 格式化 FETCH 响应时在 send() 协程 nil 指针解引用 panic,连接中断——Thunderbird 只取到崩溃前已 发送的几封邮件,手机客户端一直卡在"正在获取邮件"。 修复: - BodyStructure 解析失败时降级为 text/plain 单段结构,杜绝 nil - FetchBodySection 返回 nil 时跳过该 section,不再写入 nil literal - 新增集成测试 TestFetchBodyMalformedMIME 覆盖两类畸形 MIME --- internal/imap_server/backend.go | 31 +++++++- internal/imap_server/integration_test.go | 92 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index 15eedea..cbc4b5d 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -461,6 +461,15 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [ if err == nil { 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: section, err := imap.ParseBodySectionName(item) if err != nil { @@ -471,13 +480,33 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [ return nil, err } literal, _ := backendutil.FetchBodySection(hdr, body, section) - imapMsg.Body[section] = literal + if literal != nil { + imapMsg.Body[section] = literal + } } } 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 { if msg.RawData != "" { return []byte(msg.RawData) diff --git a/internal/imap_server/integration_test.go b/internal/imap_server/integration_test.go index 4b5049f..c9bde59 100644 --- a/internal/imap_server/integration_test.go +++ b/internal/imap_server/integration_test.go @@ -188,3 +188,95 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) { // 客户端意图是标记最新一封(ids[2])为已读 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) + } +} -- 2.47.3