Files
raven/internal/message/message_test.go
dwrz 486cc5fa52 Add answer worker
Subsystem for message processing: parses messages, generates LLM
responses, and replies with SMTP.

Introduces:
- answer: message processing worker.
- llm: OpenAI API compatible client with support for tool execution.
- message: message parsing and response logic.
- tool: converts YAML configuration into executable subprocesses.
- smtp: simple config and client wrapper for sending email.
2026-01-04 21:01:57 +00:00

1353 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
)
}
})
}
}