Files
raven/internal/message/message_test.go

1353 lines
29 KiB
Go
Raw Normal View History

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" +
"<p>HTML</p>\r\n" +
"--bound--\r\n",
Parts: []Part{
{
ContentType: "text/plain",
Content: "Plain text",
},
{
ContentType: "text/html",
Content: "<p>HTML</p>",
},
},
wantTextBody: "Plain text\n<p>HTML</p>",
},
{
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" +
"<b>HTML</b>\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: "<b>HTML</b>",
},
},
wantAttach: []Part{
{
ContentType: "image/png",
Data: []byte{1},
Filename: "img.png",
IsAttachment: true,
},
},
wantTextBody: "Plain\n<b>HTML</b>",
},
{
name: "References Header",
raw: "References: <abc@example.com> <def@example.com>\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: "<p>World</p>",
},
},
expected: []openai.ChatMessagePart{
{
Type: openai.ChatMessagePartTypeText,
Text: "Hello",
},
{
Type: openai.ChatMessagePartTypeText,
Text: "<p>World</p>",
},
},
},
{
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 <alice@example.com>, 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: "YearEnd",
},
expected: []openai.ChatMessagePart{
{
Type: openai.ChatMessagePartTypeText,
Text: "Date: 2022-12-31T23:59:59-05:00\n" +
"Subject: YearEnd\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: "MissingDate",
},
expected: []openai.ChatMessagePart{
{
Type: openai.ChatMessagePartTypeText,
Text: "From: Charlie <charlie@example.net>\n" +
"Subject: MissingDate\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: "<orig@example.com>",
expectedBodyContains: []string{
"My response",
"> Original message",
"On Mon, 15 Jan 2024 10:30:00 +0000, Alice <alice@example.com> 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 <alice@example.com> 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 <bob@ex.com> 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: "<p>Second</p>"},
},
expected: "First\n<p>Second</p>",
},
{
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 <alice@example.com>",
},
{
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 <carol@example.net>, 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,
)
}
})
}
}