perf(mail): fetch only headers for list/search (no body download)

list and search now fetch BODY.PEEK[HEADER] + BODYSTRUCTURE instead of the
whole RFC822 message, so listing a large mailbox no longer downloads every
message body and attachment. Header parsing reuses the same go-message path
(RFC2047 decoding/formatting preserved); has_attachments is derived from the
BODYSTRUCTURE tree. FetchFull keeps fetching the full message for get.

Validated end-to-end against a live IMAP account: list/search/get output
identical to the prior full-fetch behaviour, has_attachments correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 07:47:27 +01:00
parent a1440719ae
commit 8379fddbb2
4 changed files with 203 additions and 37 deletions
+31
View File
@@ -58,6 +58,37 @@ func TestParseHeaderOnly(t *testing.T) {
}
}
func TestParseHeaderBytes(t *testing.T) {
// Header-only bytes (no body), as returned by an IMAP BODY[HEADER] fetch,
// including an RFC2047-encoded subject to confirm decoding is preserved.
raw := []byte("From: \"Bob\" <bob@trusted.com>\r\n" +
"To: me@example.com\r\n" +
"Subject: =?UTF-8?Q?Caf=C3=A9=20Invoice?=\r\n" +
"Date: Sat, 20 Jun 2026 10:00:00 +0000\r\n" +
"Message-ID: <abc123@trusted.com>\r\n" +
"\r\n")
h, err := ParseHeaderBytes(9, raw)
if err != nil {
t.Fatalf("ParseHeaderBytes: %v", err)
}
if h.UID != 9 {
t.Fatalf("uid: %d", h.UID)
}
if h.Subject != "Café Invoice" {
t.Fatalf("subject not RFC2047-decoded: %q", h.Subject)
}
if h.From != `"Bob" <bob@trusted.com>` {
t.Fatalf("from: %q", h.From)
}
if h.MessageID != "abc123@trusted.com" && h.MessageID != "<abc123@trusted.com>" {
t.Fatalf("message-id: %q", h.MessageID)
}
// HasAttachments is left false — the IMAP layer sets it from BODYSTRUCTURE.
if h.HasAttachments {
t.Fatal("ParseHeaderBytes must not set HasAttachments")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {