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.
1353 lines
29 KiB
Go
1353 lines
29 KiB
Go
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: "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 <charlie@example.net>\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: "<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,
|
||
)
|
||
}
|
||
})
|
||
}
|
||
}
|