package message import ( "io" "log/slog" "strings" "testing" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-message/mail" openai "github.com/sashabaranov/go-openai" ) var log = slog.New(slog.NewTextHandler(io.Discard, nil)) var newMessageTests = []struct { name string raw string bufferOverrides func(*imapclient.FetchMessageBuffer) nilBuffer bool Parts []Part wantAttach []Part wantRefs []string wantTextBody string shouldError bool }{ { name: "Plain Text", raw: "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + "Hello, world!\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Hello, world!\r\n", }, }, wantTextBody: "Hello, world!\r\n", }, { name: "Multipart Alternative", raw: "Content-Type: multipart/alternative; boundary=bound\r\n" + "\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Plain text\r\n" + "--bound\r\n" + "Content-Type: text/html\r\n" + "\r\n" + "

HTML

\r\n" + "--bound--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Plain text", }, { ContentType: "text/html", Content: "

HTML

", }, }, wantTextBody: "Plain text\n

HTML

", }, { name: "Multipart Mixed With Attachment", raw: "Content-Type: multipart/mixed; boundary=outer\r\n" + "\r\n" + "--outer\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Message body\r\n" + "--outer\r\n" + "Content-Type: text/csv\r\n" + "Content-Disposition: attachment; filename=\"data.csv\"\r\n" + "\r\n" + "a,b,c\r\n" + "--outer--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Message body", }, }, wantAttach: []Part{ { ContentType: "text/csv", Content: "a,b,c", Filename: "data.csv", IsAttachment: true, }, }, wantTextBody: "Message body", }, { name: "Inline Image", raw: "Content-Type: multipart/mixed; boundary=bound\r\n" + "\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "See image:\r\n" + "--bound\r\n" + "Content-Type: image/png\r\n" + "Content-Disposition: inline\r\n" + "Content-Transfer-Encoding: base64\r\n" + "\r\n" + "iVBORw0KGgo=\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "What do you think?\r\n" + "--bound--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "See image:", }, { ContentType: "image/png", Data: []byte{1}, }, { ContentType: "text/plain", Content: "What do you think?", }, }, wantTextBody: "See image:\nWhat do you think?", }, { name: "Image Attachment", raw: "Content-Type: multipart/mixed; boundary=bound\r\n" + "\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Please review attached image.\r\n" + "--bound\r\n" + "Content-Type: image/jpeg\r\n" + "Content-Disposition: attachment; filename=\"photo.jpg\"\r\n" + "Content-Transfer-Encoding: base64\r\n" + "\r\n" + "/9j/4AAQ\r\n" + "--bound--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Please review attached image.", }, }, wantAttach: []Part{ { ContentType: "image/jpeg", Data: []byte{1}, Filename: "photo.jpg", IsAttachment: true, }, }, wantTextBody: "Please review attached image.", }, { name: "Nested Multipart", raw: "Content-Type: multipart/mixed; boundary=outer\r\n" + "\r\n" + "--outer\r\n" + "Content-Type: multipart/alternative; boundary=inner\r\n" + "\r\n" + "--inner\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Plain\r\n" + "--inner\r\n" + "Content-Type: text/html\r\n" + "\r\n" + "HTML\r\n" + "--inner--\r\n" + "--outer\r\n" + "Content-Type: image/png\r\n" + "Content-Disposition: attachment; filename=\"img.png\"\r\n" + "\r\n" + "PNG-DATA\r\n" + "--outer--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Plain", }, { ContentType: "text/html", Content: "HTML", }, }, wantAttach: []Part{ { ContentType: "image/png", Data: []byte{1}, Filename: "img.png", IsAttachment: true, }, }, wantTextBody: "Plain\nHTML", }, { name: "References Header", raw: "References: \r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Body\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Body\r\n", }, }, wantRefs: []string{ "abc@example.com", "def@example.com", }, wantTextBody: "Body\r\n", }, { name: "Quoted Printable", raw: "Content-Type: text/plain; charset=utf-8\r\n" + "Content-Transfer-Encoding: quoted-printable\r\n" + "\r\n" + "Hello=20World\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Hello World\r\n", }, }, wantTextBody: "Hello World\r\n", }, { name: "Base64 Text", raw: "Content-Type: text/plain; charset=utf-8\r\n" + "Content-Transfer-Encoding: base64\r\n" + "\r\n" + "SGVsbG8gV29ybGQ=\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Hello World", }, }, wantTextBody: "Hello World", }, { name: "No Content Type", raw: "\r\nPlain body with no headers\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Plain body with no headers\r\n", }, }, wantTextBody: "Plain body with no headers\r\n", }, { name: "Multiple Text Parts", raw: "Content-Type: multipart/mixed; boundary=bound\r\n" + "\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "First part\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Second part\r\n" + "--bound--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "First part", }, { ContentType: "text/plain", Content: "Second part", }, }, wantTextBody: "First part\nSecond part", }, { name: "Skips Unsupported Attachment", raw: "Content-Type: multipart/mixed; boundary=bound\r\n" + "\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Message\r\n" + "--bound\r\n" + "Content-Type: application/pdf\r\n" + "Content-Disposition: attachment; filename=\"doc.pdf\"\r\n" + "\r\n" + "PDF-DATA\r\n" + "--bound--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Message", }, }, wantAttach: nil, wantTextBody: "Message", }, { name: "Skips Unsupported Inline", raw: "Content-Type: multipart/mixed; boundary=bound\r\n" + "\r\n" + "--bound\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Message\r\n" + "--bound\r\n" + "Content-Type: audio/mpeg\r\n" + "\r\n" + "AUDIO-DATA\r\n" + "--bound--\r\n", Parts: []Part{ { ContentType: "text/plain", Content: "Message", }, }, wantTextBody: "Message", }, { name: "Nil Buffer", nilBuffer: true, shouldError: true, }, { name: "Missing UID", raw: "Subject: x\r\n\r\nBody", bufferOverrides: func( mb *imapclient.FetchMessageBuffer, ) { mb.UID = 0 }, shouldError: true, }, { name: "Missing Envelope", raw: "Subject: x\r\n\r\nBody", bufferOverrides: func( mb *imapclient.FetchMessageBuffer, ) { mb.Envelope = nil }, shouldError: true, }, { name: "Empty Body Section", raw: "Content-Type: text/plain\r\n\r\nBody\r\n", bufferOverrides: func( mb *imapclient.FetchMessageBuffer, ) { // Prepend an empty section to verify it gets skipped. emptySec := imapclient.FetchBodySectionBuffer{ Section: &imap.FetchItemBodySection{}, Bytes: []byte{}, } mb.BodySection = append( []imapclient.FetchBodySectionBuffer{ emptySec, }, mb.BodySection..., ) }, Parts: []Part{ { ContentType: "text/plain", Content: "Body\r\n", }, }, wantTextBody: "Body\r\n", }, } // TestNew verifies MIME parsing across message structures: // plain text, multipart/alternative, multipart/mixed with attachments, // nested multipart, transfer encodings (quoted-printable, base64), // inline images, and error conditions. func TestNew(t *testing.T) { for _, tt := range newMessageTests { t.Run(tt.name, func(t *testing.T) { var mb *imapclient.FetchMessageBuffer if !tt.nilBuffer { mb = &imapclient.FetchMessageBuffer{ UID: 1, Envelope: &imap.Envelope{}, BodySection: []imapclient.FetchBodySectionBuffer{ { Section: &imap.FetchItemBodySection{}, Bytes: []byte(tt.raw), }, }, } if tt.bufferOverrides != nil { tt.bufferOverrides(mb) } } msg, err := New(mb, log) if tt.shouldError { if err == nil { t.Fatal("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if msg == nil { t.Fatal("expected message, got nil") } // Verify parts. if len(msg.Parts) != len(tt.Parts) { t.Fatalf( "Parts count: got %d, want %d", len(msg.Parts), len(tt.Parts), ) } for i, want := range tt.Parts { got := msg.Parts[i] if !strings.HasPrefix( got.ContentType, want.ContentType, ) { t.Errorf( "Parts[%d] content-type: got %q, want prefix %q", i, got.ContentType, want.ContentType, ) } if want.Content != "" && got.Content != want.Content { t.Errorf( "Parts[%d] Content: got %q, want %q", i, got.Content, want.Content, ) } if len(want.Data) > 0 && len(got.Data) == 0 { t.Errorf( "Parts[%d] expected data, got empty", i, ) } } // Verify attachments. if len(msg.Attachments) != len(tt.wantAttach) { t.Fatalf( "Attachments count: got %d, want %d", len(msg.Attachments), len(tt.wantAttach)) } for i, want := range tt.wantAttach { got := msg.Attachments[i] if !strings.HasPrefix( got.ContentType, want.ContentType, ) { t.Errorf( "Attachments[%d] type: got %q, want prefix %q", i, got.ContentType, want.ContentType, ) } if want.Filename != "" && got.Filename != want.Filename { t.Errorf( "Attachments[%d] Filename: got %q, want %q", i, got.Filename, want.Filename, ) } if want.Content != "" && got.Content != want.Content { t.Errorf( "Attachments[%d] Content: got %q, want %q", i, got.Content, want.Content, ) } if len(want.Data) > 0 && len(got.Data) == 0 { t.Errorf( "Attachments[%d] expected data, got empty", i, ) } if !got.IsAttachment { t.Errorf( "Attachments[%d] IsAttachment: got false, want true", i, ) } } // Verify TextBody. if tt.wantTextBody != "" && msg.TextBody() != tt.wantTextBody { t.Errorf( "TextBody: got %q, want %q", msg.TextBody(), tt.wantTextBody, ) } // Verify References. if len(tt.wantRefs) > 0 { if len(msg.References) != len(tt.wantRefs) { t.Errorf( "References count: got %d, want %d", len(msg.References), len(tt.wantRefs), ) } for i, wantRef := range tt.wantRefs { if i < len(msg.References) && msg.References[i] != wantRef { t.Errorf( "References[%d]: got %q, want %q", i, msg.References[i], wantRef, ) } } } }) } } var chatTests = []struct { name string parts []Part attachments []Part envelope *imap.Envelope expected []openai.ChatMessagePart }{ { name: "Text Only", parts: []Part{ { ContentType: "text/plain", Content: "Hello", }, { ContentType: "text/html", Content: "

World

", }, }, expected: []openai.ChatMessagePart{ { Type: openai.ChatMessagePartTypeText, Text: "Hello", }, { Type: openai.ChatMessagePartTypeText, Text: "

World

", }, }, }, { name: "Text And Image", parts: []Part{ { ContentType: "text/plain", Content: "See this:", }, { ContentType: "image/png", Data: []byte("PNG"), }, }, expected: []openai.ChatMessagePart{ { Type: openai.ChatMessagePartTypeText, Text: "See this:", }, { Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{ URL: "data:image/png;base64,UE5H", Detail: openai.ImageURLDetailAuto, }, }, }, }, { name: "Parts Then Attachments", parts: []Part{ {ContentType: "text/plain", Content: "Body"}, }, attachments: []Part{ { ContentType: "image/jpeg", Data: []byte("JPG"), IsAttachment: true, }, }, expected: []openai.ChatMessagePart{ { Type: openai.ChatMessagePartTypeText, Text: "Body", }, { Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{ URL: "data:image/jpeg;base64,SlBH", Detail: openai.ImageURLDetailAuto, }, }, }, }, { name: "Skips Unsupported Types", parts: []Part{ {ContentType: "text/plain", Content: "Text"}, {ContentType: "application/pdf", Data: []byte("PDF")}, }, expected: []openai.ChatMessagePart{ {Type: openai.ChatMessagePartTypeText, Text: "Text"}, }, }, { name: "Order Preserved", parts: []Part{ {ContentType: "text/plain", Content: "First"}, {ContentType: "image/gif", Data: []byte("GIF1")}, {ContentType: "text/csv", Content: "a,b,c"}, {ContentType: "image/webp", Data: []byte("WEBP")}, {ContentType: "text/plain", Content: "Last"}, }, expected: []openai.ChatMessagePart{ {Type: openai.ChatMessagePartTypeText, Text: "First"}, { Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{ URL: "data:image/gif;base64,R0lGMQ==", Detail: openai.ImageURLDetailAuto, }, }, {Type: openai.ChatMessagePartTypeText, Text: "a,b,c"}, {Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{ URL: "data:image/webp;base64,V0VCUA==", Detail: openai.ImageURLDetailAuto, }}, {Type: openai.ChatMessagePartTypeText, Text: "Last"}, }, }, { name: "HeaderWithAllFields", parts: []Part{ {ContentType: "text/plain", Content: "body"}, }, envelope: &imap.Envelope{ From: []imap.Address{ { Name: "Alice", Mailbox: "alice", Host: "example.com", }, { Mailbox: "bob", Host: "example.org", }, }, Date: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), Subject: "Test Subject", }, expected: []openai.ChatMessagePart{ { // The header part – From, Date and Subject. Type: openai.ChatMessagePartTypeText, Text: "From: Alice , bob@example.org\n" + "Date: 2024-01-15T10:30:00Z\n" + "Subject: Test Subject\n", }, { // The body part that follows the header. Type: openai.ChatMessagePartTypeText, Text: "body", }, }, }, { name: "HeaderWithoutFrom", parts: []Part{ {ContentType: "text/plain", Content: "only date"}, }, envelope: &imap.Envelope{ Date: time.Date(2022, 12, 31, 23, 59, 59, 0, time.FixedZone("-05:00", -5*3600)), Subject: "Year‑End", }, expected: []openai.ChatMessagePart{ { Type: openai.ChatMessagePartTypeText, Text: "Date: 2022-12-31T23:59:59-05:00\n" + "Subject: Year‑End\n", }, { Type: openai.ChatMessagePartTypeText, Text: "only date", }, }, }, { name: "HeaderWithoutDate", parts: []Part{ {ContentType: "text/plain", Content: "no date"}, }, envelope: &imap.Envelope{ From: []imap.Address{ { Name: "Charlie", Mailbox: "charlie", Host: "example.net", }, }, Subject: "Missing‑Date", }, expected: []openai.ChatMessagePart{ { Type: openai.ChatMessagePartTypeText, Text: "From: Charlie \n" + "Subject: Missing‑Date\n", }, { Type: openai.ChatMessagePartTypeText, Text: "no date", }, }, }, { name: "NilEnvelope", parts: []Part{ {ContentType: "text/plain", Content: "just body"}, }, // envelope left nil – we expect *no* header part. expected: []openai.ChatMessagePart{ { Type: openai.ChatMessagePartTypeText, Text: "just body", }, }, }, } // TestToOpenAIMessages verifies conversion to OpenAI multimodal format. func TestToOpenAIMessages(t *testing.T) { for _, tt := range chatTests { t.Run(tt.name, func(t *testing.T) { msg := &Message{ Parts: tt.parts, Attachments: tt.attachments, Envelope: tt.envelope, } got := msg.ToOpenAIMessages() if len(got) != len(tt.expected) { t.Fatalf( "Part count: got %d, want %d", len(got), len(tt.expected), ) } for i, want := range tt.expected { g := got[i] if g.Type != want.Type { t.Errorf( "[%d] Type: got %q, want %q", i, g.Type, want.Type, ) } if want.Type == openai.ChatMessagePartTypeText { if g.Text != want.Text { t.Errorf( "[%d] Text: got %q, want %q", i, g.Text, want.Text, ) } } if want.Type == openai.ChatMessagePartTypeImageURL { if g.ImageURL == nil { t.Errorf( "[%d] ImageURL is nil", i, ) continue } if g.ImageURL.URL != want.ImageURL.URL { t.Errorf( "[%d] ImageURL.URL: got %q, want %q", i, g.ImageURL.URL, want.ImageURL.URL, ) } if g.ImageURL.Detail != want.ImageURL.Detail { t.Errorf( "[%d] ImageURL.Detail: got %q, want %q", i, g.ImageURL.Detail, want.ImageURL.Detail, ) } } } }) } } var fixedDate = time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) var defaultFrom = &mail.Address{Name: "Bob", Address: "bob@example.com"} var replyTests = []struct { name string msg *Message from *mail.Address replyText string shouldError bool expectedRecipients []string expectedSubject string expectedInReplyTo string expectedReferences []string expectedBodyContains []string }{ { name: "Basic Reply", msg: &Message{ Envelope: &imap.Envelope{ Subject: "Test", MessageID: "orig@example.com", From: []imap.Address{{ Name: "Alice", Mailbox: "alice", Host: "example.com", }}, Date: fixedDate, }, Parts: []Part{{ ContentType: "text/plain", Content: "Original message", }}, }, replyText: "My response", expectedRecipients: []string{"alice@example.com"}, expectedSubject: "Re: Test", expectedInReplyTo: "", expectedBodyContains: []string{ "My response", "> Original message", "On Mon, 15 Jan 2024 10:30:00 +0000, Alice wrote:", }, }, { name: "Uses Reply-To", msg: &Message{ Envelope: &imap.Envelope{ Subject: "Test", From: []imap.Address{{ Mailbox: "from", Host: "example.com", }}, ReplyTo: []imap.Address{{ Mailbox: "replyto", Host: "example.com", }}, Date: fixedDate, }, Parts: []Part{{ ContentType: "text/plain", Content: "Body", }}, }, from: &mail.Address{Address: "me@example.com"}, replyText: "Response", expectedRecipients: []string{"replyto@example.com"}, }, { name: "Subject Already Re", msg: &Message{ Envelope: &imap.Envelope{ Subject: "Re: Already replied", From: []imap.Address{{ Mailbox: "from", Host: "example.com", }}, }, Parts: []Part{{ ContentType: "text/plain", Content: "Body", }}, }, replyText: "Response", expectedSubject: "Re: Already replied", }, { name: "Thread References", msg: &Message{ Envelope: &imap.Envelope{ Subject: "Thread", MessageID: "msg3@example.com", From: []imap.Address{{ Mailbox: "from", Host: "example.com", }}, }, References: []string{ "msg1@example.com", "msg2@example.com", }, Parts: []Part{{ ContentType: "text/plain", Content: "Body", }}, }, replyText: "Response", expectedReferences: []string{ "msg1@example.com", "msg2@example.com", "msg3@example.com", }, }, { name: "Nil Message", msg: nil, shouldError: true, }, { name: "Nil Envelope", msg: &Message{}, shouldError: true, }, { name: "Nil From", msg: &Message{ Envelope: &imap.Envelope{ From: []imap.Address{{ Mailbox: "from", Host: "example.com", }}, }, }, from: nil, shouldError: true, }, { name: "Empty From Address", msg: &Message{ Envelope: &imap.Envelope{ From: []imap.Address{{ Mailbox: "from", Host: "example.com", }}, }, }, from: &mail.Address{Name: "Name Only"}, shouldError: true, }, { name: "From Address No At Sign", msg: &Message{ Envelope: &imap.Envelope{ From: []imap.Address{{ Mailbox: "from", Host: "example.com", }}, }, }, from: &mail.Address{Address: "localonly"}, shouldError: false, }, { name: "No Recipients", msg: &Message{ Envelope: &imap.Envelope{Subject: "Test"}, Parts: []Part{{ ContentType: "text/plain", Content: "Body", }}, }, shouldError: true, }, } // TestComposeReply verifies reply composition: recipient selection, // subject handling, threading headers, and error conditions. func TestComposeReply(t *testing.T) { for _, tt := range replyTests { t.Run(tt.name, func(t *testing.T) { useFrom := tt.from // Use default sender for non-error cases when from is // not specified. if useFrom == nil && !tt.shouldError { useFrom = defaultFrom } reply, err := tt.msg.ComposeReply( fixedDate, useFrom, tt.replyText, ) if tt.shouldError { if err == nil { t.Fatal("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if len(tt.expectedRecipients) > 0 { recipients, err := reply.Recipients() if err != nil { t.Fatalf("Recipients() error: %v", err) } if len(recipients) != len(tt.expectedRecipients) { t.Errorf( "Recipients count: got %d, want %d", len(recipients), len(tt.expectedRecipients), ) } for i, want := range tt.expectedRecipients { if i < len(recipients) && !strings.Contains( recipients[i], want, ) { t.Errorf( "Recipient[%d]: got %q, want substring %q", i, recipients[i], want, ) } } } data, err := reply.Bytes() if err != nil { t.Fatalf("Bytes() error: %v", err) } bodyStr := string(data) if tt.expectedSubject != "" && !strings.Contains( bodyStr, "Subject: "+tt.expectedSubject, ) { t.Errorf( "Subject missing or mismatch.\nBody: %s\nExpected Subject: %s", bodyStr, tt.expectedSubject, ) } if tt.expectedInReplyTo != "" { if !strings.Contains( bodyStr, "In-Reply-To: "+tt.expectedInReplyTo, ) { t.Errorf( "In-Reply-To missing.\nBody: %s\nExpected: %s", bodyStr, tt.expectedInReplyTo, ) } } for _, ref := range tt.expectedReferences { if !strings.Contains(bodyStr, ref) { t.Errorf( "References header missing ID %q", ref, ) } } for _, part := range tt.expectedBodyContains { if !strings.Contains(bodyStr, part) { t.Errorf( "Body content missing: %q", part, ) } } }) } } var attributionTests = []struct { name string envelope *imap.Envelope expected string }{ { name: "No From", envelope: &imap.Envelope{}, expected: "> \n", }, { name: "No Date", envelope: &imap.Envelope{ From: []imap.Address{{ Name: "Alice", Mailbox: "alice", Host: "example.com", }}, }, expected: "Alice wrote:\n", }, { name: "No Name", envelope: &imap.Envelope{ From: []imap.Address{{ Mailbox: "alice", Host: "example.com", }}, }, expected: "alice@example.com wrote:\n", }, { name: "Full", envelope: &imap.Envelope{ From: []imap.Address{{ Name: "Bob", Mailbox: "bob", Host: "ex.com", }}, Date: time.Date( 2024, 1, 2, 15, 4, 5, 0, time.FixedZone("", -25200), ), }, expected: "On Tue, 2 Jan 2024 15:04:05 -0700, Bob wrote:\n", }, } // TestComposeAttribution verifies attribution line formatting for various // sender/date combinations. func TestComposeAttribution(t *testing.T) { for _, tt := range attributionTests { t.Run(tt.name, func(t *testing.T) { msg := &Message{Envelope: tt.envelope} if got := msg.composeAttribution(); got != tt.expected { t.Errorf("got %q, want %q", got, tt.expected) } }) } } var quoteTests = []struct { name string parts []Part expected string }{ { name: "Simple", parts: []Part{ {ContentType: "text/plain", Content: "line1\nline2\n"}, }, expected: "> line1\n> line2\n> \n", }, { name: "Strips CR", parts: []Part{ { ContentType: "text/plain", Content: "line1\r\nline2\r\n", }, }, expected: "> line1\n> line2\n> \n", }, { name: "Multiple Parts", parts: []Part{ {ContentType: "text/plain", Content: "first"}, {ContentType: "text/plain", Content: "second"}, }, expected: "> first\n> second\n", }, { name: "Ignores Images", parts: []Part{ {ContentType: "text/plain", Content: "text"}, {ContentType: "image/png", Data: []byte("PNG")}, }, expected: "> text\n", }, } // TestQuotedBody verifies line quoting and CR stripping. func TestQuotedBody(t *testing.T) { for _, tt := range quoteTests { t.Run(tt.name, func(t *testing.T) { msg := &Message{Parts: tt.parts, Envelope: &imap.Envelope{}} if got := msg.QuotedBody(); got != tt.expected { t.Errorf("got %q, want %q", got, tt.expected) } }) } } var textBodyTests = []struct { name string parts []Part expected string }{ { name: "Single Text Part", parts: []Part{ {ContentType: "text/plain", Content: "Hello"}, }, expected: "Hello", }, { name: "Multiple Text Parts", parts: []Part{ {ContentType: "text/plain", Content: "First"}, {ContentType: "text/html", Content: "

Second

"}, }, expected: "First\n

Second

", }, { name: "Mixed With Images", parts: []Part{ {ContentType: "text/plain", Content: "Before"}, {ContentType: "image/png", Data: []byte("PNG")}, {ContentType: "text/plain", Content: "After"}, }, expected: "Before\nAfter", }, { name: "No Text Parts", parts: []Part{{ ContentType: "image/jpeg", Data: []byte("JPG"), }}, expected: "", }, } // TestTextBody verifies text extraction from parts. func TestTextBody(t *testing.T) { for _, tt := range textBodyTests { t.Run(tt.name, func(t *testing.T) { msg := &Message{Parts: tt.parts} if got := msg.TextBody(); got != tt.expected { t.Errorf("got %q, want %q", got, tt.expected) } }) } } // TestTextFrom checks the formatting of the “From:” line produced by // Message.TextFrom. func TestTextFrom(t *testing.T) { tests := []struct { name string env *imap.Envelope expected string }{ { name: "Nil envelope", env: nil, expected: "", }, { name: "No From addresses", env: &imap.Envelope{ From: []imap.Address{}, }, expected: "", }, { name: "Single address with name", env: &imap.Envelope{ From: []imap.Address{ { Name: "Alice", Mailbox: "alice", Host: "example.com", }, }, }, expected: "From: Alice ", }, { name: "Single address without name", env: &imap.Envelope{ From: []imap.Address{ { Mailbox: "bob", Host: "example.org", }, }, }, expected: "From: bob@example.org", }, { name: "Multiple mixed addresses", env: &imap.Envelope{ From: []imap.Address{ { Name: "Carol", Mailbox: "carol", Host: "example.net", }, { Mailbox: "dave", Host: "example.net", }, }, }, expected: "From: Carol , dave@example.net", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { msg := &Message{Envelope: tt.env} got := msg.TextFrom() if got != tt.expected { t.Errorf("TextFrom() = %q, want %q", got, tt.expected) } }) } } // TestPartHelpers verifies Part.IsImage and Part.IsText methods. func TestPartHelpers(t *testing.T) { var tests = []struct { contentType string isText bool isImage bool }{ {"application/pdf", false, false}, {"audio/mpeg", false, false}, {"image/gif", false, true}, {"image/jpeg", false, true}, {"image/png", false, true}, {"image/svg+xml", false, false}, {"image/webp", false, true}, {"text/csv", true, false}, {"text/html", true, false}, {"text/plain", true, false}, } for _, tt := range tests { t.Run(tt.contentType, func(t *testing.T) { p := Part{ContentType: tt.contentType} if got := p.IsText(); got != tt.isText { t.Errorf( "IsText: got %v, want %v", got, tt.isText, ) } if got := p.IsImage(); got != tt.isImage { t.Errorf( "IsImage: got %v, want %v", got, tt.isImage, ) } }) } }