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