diff --git a/cleanup_test.go b/cleanup_test.go new file mode 100644 index 0000000..d827e73 --- /dev/null +++ b/cleanup_test.go @@ -0,0 +1,136 @@ +package smtp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestCleanupTempFiles(t *testing.T) { + tmpDir := t.TempDir() + log, _ := zap.NewDevelopment() + + p := &Plugin{ + cfg: &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "tempfile", + TempDir: tmpDir, + CleanupAfter: 50 * time.Millisecond, + }, + }, + log: log, + } + + // Create old files (matching pattern) + oldFile := filepath.Join(tmpDir, "smtp-att-old-file.txt") + os.WriteFile(oldFile, []byte("old"), 0644) + // Backdate the file + past := time.Now().Add(-1 * time.Hour) + os.Chtimes(oldFile, past, past) + + // Create recent file (matching pattern) + recentFile := filepath.Join(tmpDir, "smtp-att-recent-file.txt") + os.WriteFile(recentFile, []byte("recent"), 0644) + + // Create non-matching file + otherFile := filepath.Join(tmpDir, "other-file.txt") + os.WriteFile(otherFile, []byte("other"), 0644) + os.Chtimes(otherFile, past, past) + + p.cleanupTempFiles() + + // Old matching file should be removed + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Error("old smtp-att file should have been removed") + } + + // Recent matching file should remain + if _, err := os.Stat(recentFile); err != nil { + t.Error("recent smtp-att file should NOT have been removed") + } + + // Non-matching file should remain + if _, err := os.Stat(otherFile); err != nil { + t.Error("non-matching file should NOT have been removed") + } +} + +func TestCleanupTempFiles_NonexistentDir(t *testing.T) { + log, _ := zap.NewDevelopment() + p := &Plugin{ + cfg: &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "tempfile", + TempDir: "/nonexistent/path", + CleanupAfter: time.Hour, + }, + }, + log: log, + } + + // Should not panic + p.cleanupTempFiles() +} + +func TestStartCleanupRoutine_SkipsMemoryMode(t *testing.T) { + log, _ := zap.NewDevelopment() + p := &Plugin{ + cfg: &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "memory", + }, + }, + log: log, + } + + // Should not start goroutine or panic + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p.startCleanupRoutine(ctx) +} + +func TestStartCleanupRoutine_StopsOnCancel(t *testing.T) { + tmpDir := t.TempDir() + log, _ := zap.NewDevelopment() + + p := &Plugin{ + cfg: &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "tempfile", + TempDir: tmpDir, + CleanupAfter: 10 * time.Millisecond, + }, + }, + log: log, + } + + ctx, cancel := context.WithCancel(context.Background()) + p.startCleanupRoutine(ctx) + + // Let it tick at least once + time.Sleep(30 * time.Millisecond) + + // Cancel should stop the goroutine + cancel() + + // Give goroutine time to exit + time.Sleep(20 * time.Millisecond) + + // Create a file after cancel - it should NOT be cleaned up + testFile := filepath.Join(tmpDir, "smtp-att-after-cancel.txt") + os.WriteFile(testFile, []byte("test"), 0644) + past := time.Now().Add(-1 * time.Hour) + os.Chtimes(testFile, past, past) + + // Wait more than the ticker interval + time.Sleep(30 * time.Millisecond) + + // File should still exist since cleanup routine was cancelled + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Error("file should still exist after cleanup routine was cancelled") + } +} diff --git a/config.go b/config.go index 18c8ed5..b3dc995 100644 --- a/config.go +++ b/config.go @@ -50,8 +50,6 @@ func (c *Config) InitDefaults() error { c.Hostname = "localhost" } - c.IncludeRaw = true - if c.ReadTimeout == 0 { c.ReadTimeout = 60 * time.Second } diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..02e6906 --- /dev/null +++ b/config_test.go @@ -0,0 +1,141 @@ +package smtp + +import ( + "testing" + "time" +) + +func TestInitDefaults_SetsAllDefaults(t *testing.T) { + cfg := &Config{} + cfg.Jobs.Pipeline = "test" // required field + + if err := cfg.InitDefaults(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Addr != "127.0.0.1:1025" { + t.Errorf("expected default addr 127.0.0.1:1025, got %s", cfg.Addr) + } + if cfg.Hostname != "localhost" { + t.Errorf("expected default hostname localhost, got %s", cfg.Hostname) + } + if cfg.ReadTimeout != 60*time.Second { + t.Errorf("expected default read_timeout 60s, got %v", cfg.ReadTimeout) + } + if cfg.WriteTimeout != 10*time.Second { + t.Errorf("expected default write_timeout 10s, got %v", cfg.WriteTimeout) + } + if cfg.MaxMessageSize != 10*1024*1024 { + t.Errorf("expected default max_message_size 10MB, got %d", cfg.MaxMessageSize) + } + if cfg.AttachmentStorage.Mode != "memory" { + t.Errorf("expected default attachment mode memory, got %s", cfg.AttachmentStorage.Mode) + } + if cfg.AttachmentStorage.TempDir != "/tmp/smtp-attachments" { + t.Errorf("expected default temp_dir, got %s", cfg.AttachmentStorage.TempDir) + } + if cfg.AttachmentStorage.CleanupAfter != time.Hour { + t.Errorf("expected default cleanup_after 1h, got %v", cfg.AttachmentStorage.CleanupAfter) + } + if cfg.Jobs.Priority != 10 { + t.Errorf("expected default priority 10, got %d", cfg.Jobs.Priority) + } +} + +func TestInitDefaults_DoesNotOverrideIncludeRaw(t *testing.T) { + cfg := &Config{ + IncludeRaw: false, + } + cfg.Jobs.Pipeline = "test" + + if err := cfg.InitDefaults(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.IncludeRaw != false { + t.Error("IncludeRaw should remain false when explicitly set") + } +} + +func TestInitDefaults_PreservesUserValues(t *testing.T) { + cfg := &Config{ + Addr: "0.0.0.0:2525", + Hostname: "mail.example.com", + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Second, + MaxMessageSize: 5 * 1024 * 1024, + Jobs: JobsConfig{Pipeline: "emails", Priority: 5}, + } + + if err := cfg.InitDefaults(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Addr != "0.0.0.0:2525" { + t.Errorf("addr was overwritten: %s", cfg.Addr) + } + if cfg.Hostname != "mail.example.com" { + t.Errorf("hostname was overwritten: %s", cfg.Hostname) + } + if cfg.ReadTimeout != 30*time.Second { + t.Errorf("read_timeout was overwritten: %v", cfg.ReadTimeout) + } + if cfg.Jobs.Priority != 5 { + t.Errorf("priority was overwritten: %d", cfg.Jobs.Priority) + } +} + +func TestValidate_MissingPipeline(t *testing.T) { + cfg := &Config{ + Addr: "127.0.0.1:1025", + AttachmentStorage: AttachmentConfig{Mode: "memory"}, + Jobs: JobsConfig{Pipeline: ""}, + } + + err := cfg.validate() + if err == nil { + t.Error("expected validation error for empty pipeline") + } +} + +func TestValidate_NegativeMaxMessageSize(t *testing.T) { + cfg := &Config{ + Addr: "127.0.0.1:1025", + MaxMessageSize: -1, + AttachmentStorage: AttachmentConfig{Mode: "memory"}, + Jobs: JobsConfig{Pipeline: "test"}, + } + + err := cfg.validate() + if err == nil { + t.Error("expected validation error for negative max_message_size") + } +} + +func TestValidate_InvalidAttachmentMode(t *testing.T) { + cfg := &Config{ + Addr: "127.0.0.1:1025", + AttachmentStorage: AttachmentConfig{Mode: "invalid"}, + Jobs: JobsConfig{Pipeline: "test"}, + } + + err := cfg.validate() + if err == nil { + t.Error("expected validation error for invalid attachment mode") + } +} + +func TestValidate_ValidConfig(t *testing.T) { + for _, mode := range []string{"memory", "tempfile"} { + t.Run(mode, func(t *testing.T) { + cfg := &Config{ + Addr: "127.0.0.1:1025", + AttachmentStorage: AttachmentConfig{Mode: mode}, + Jobs: JobsConfig{Pipeline: "test"}, + } + if err := cfg.validate(); err != nil { + t.Errorf("unexpected validation error for mode %s: %v", mode, err) + } + }) + } +} diff --git a/jobs_integration.go b/jobs_integration.go index 8707b07..8338b69 100644 --- a/jobs_integration.go +++ b/jobs_integration.go @@ -3,6 +3,7 @@ package smtp import ( "context" "encoding/json" + "fmt" "github.com/google/uuid" "github.com/roadrunner-server/api/v4/plugins/v4/jobs" @@ -112,8 +113,11 @@ func (j *Job) UpdatePriority(p int64) { } // emailToJobMessage converts EmailData to a jobs.Message for the Jobs plugin -func emailToJobMessage(email *EmailData, cfg *JobsConfig) jobs.Message { - payload, _ := json.Marshal(email) +func emailToJobMessage(email *EmailData, cfg *JobsConfig) (jobs.Message, error) { + payload, err := json.Marshal(email) + if err != nil { + return nil, fmt.Errorf("failed to marshal email data: %w", err) + } // Generate a unique job ID jobID := uuid.NewString() @@ -132,5 +136,5 @@ func emailToJobMessage(email *EmailData, cfg *JobsConfig) jobs.Message { Delay: cfg.Delay, AutoAck: cfg.AutoAck, }, - } + }, nil } diff --git a/jobs_integration_test.go b/jobs_integration_test.go new file mode 100644 index 0000000..7305399 --- /dev/null +++ b/jobs_integration_test.go @@ -0,0 +1,263 @@ +package smtp + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/roadrunner-server/api/v4/plugins/v4/jobs" + "go.uber.org/zap" +) + +// mockJobs implements the Jobs interface for testing +type mockJobs struct { + pushed []jobs.Message + err error +} + +func (m *mockJobs) Push(_ context.Context, msg jobs.Message) error { + if m.err != nil { + return m.err + } + m.pushed = append(m.pushed, msg) + return nil +} + +func TestEmailToJobMessage_Success(t *testing.T) { + email := &EmailData{ + Event: "EMAIL_RECEIVED", + UUID: "test-uuid-123", + RemoteAddr: "127.0.0.1:12345", + ReceivedAt: time.Now(), + Envelope: EnvelopeData{ + From: []EmailAddress{{Email: "sender@test.com", Name: "Sender"}}, + To: []EmailAddress{{Email: "recipient@test.com"}}, + }, + Message: MessageData{ + Subject: "Test Subject", + Body: "Test body", + }, + } + + cfg := &JobsConfig{ + Pipeline: "smtp-emails", + Priority: 5, + Delay: 10, + AutoAck: true, + } + + msg, err := emailToJobMessage(email, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg.Name() != "smtp.email" { + t.Errorf("expected job name smtp.email, got %s", msg.Name()) + } + if msg.Priority() != 5 { + t.Errorf("expected priority 5, got %d", msg.Priority()) + } + if msg.Delay() != 10 { + t.Errorf("expected delay 10, got %d", msg.Delay()) + } + if msg.AutoAck() != true { + t.Error("expected auto_ack true") + } + if msg.GroupID() != "smtp-emails" { + t.Errorf("expected group smtp-emails, got %s", msg.GroupID()) + } + + // Check headers + headers := msg.Headers() + if v := headers["uuid"]; len(v) == 0 || v[0] != "test-uuid-123" { + t.Errorf("expected uuid header, got %v", v) + } + if v := headers["payload_class"]; len(v) == 0 || v[0] != "smtp:handler" { + t.Errorf("expected payload_class header, got %v", v) + } + + // Verify payload is valid JSON + var decoded EmailData + if err := json.Unmarshal(msg.Payload(), &decoded); err != nil { + t.Fatalf("failed to unmarshal payload: %v", err) + } + if decoded.UUID != "test-uuid-123" { + t.Errorf("expected UUID test-uuid-123 in payload, got %s", decoded.UUID) + } + if decoded.Message.Subject != "Test Subject" { + t.Errorf("expected subject in payload, got %s", decoded.Message.Subject) + } +} + +func TestEmailToJobMessage_UniqueIDs(t *testing.T) { + email := &EmailData{UUID: "test"} + cfg := &JobsConfig{Pipeline: "test"} + + msg1, err := emailToJobMessage(email, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + msg2, err := emailToJobMessage(email, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if msg1.ID() == msg2.ID() { + t.Error("expected unique job IDs for each call") + } +} + +func TestPushToJobs_Success(t *testing.T) { + log, _ := zap.NewDevelopment() + mock := &mockJobs{} + p := &Plugin{ + jobs: mock, + cfg: &Config{Jobs: JobsConfig{Pipeline: "test", Priority: 10}}, + log: log, + } + + email := &EmailData{ + UUID: "push-test", + Message: MessageData{ + Subject: "Push Test", + Body: "body", + }, + } + + err := p.pushToJobs(email) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(mock.pushed) != 1 { + t.Fatalf("expected 1 push, got %d", len(mock.pushed)) + } + if mock.pushed[0].Name() != "smtp.email" { + t.Errorf("expected job name smtp.email, got %s", mock.pushed[0].Name()) + } +} + +func TestPushToJobs_Error(t *testing.T) { + log, _ := zap.NewDevelopment() + mock := &mockJobs{err: errors.New("push failed")} + p := &Plugin{ + jobs: mock, + cfg: &Config{Jobs: JobsConfig{Pipeline: "test", Priority: 10}}, + log: log, + } + + email := &EmailData{UUID: "error-test"} + err := p.pushToJobs(email) + if err == nil { + t.Error("expected error, got nil") + } +} + +func TestPushToJobs_NilJobsPlugin(t *testing.T) { + log, _ := zap.NewDevelopment() + p := &Plugin{ + jobs: nil, + cfg: &Config{Jobs: JobsConfig{Pipeline: "test"}}, + log: log, + } + + email := &EmailData{UUID: "nil-jobs-test"} + err := p.pushToJobs(email) + if err == nil { + t.Error("expected error for nil jobs plugin") + } +} + +func TestJobInterfaceMethods(t *testing.T) { + job := &Job{ + Job: "test.job", + Ident: "job-123", + Pld: []byte(`{"key":"value"}`), + Hdr: map[string][]string{"h1": {"v1"}}, + Options: &JobOptions{ + Priority: 5, + Pipeline: "pipe", + Delay: 100, + AutoAck: true, + }, + } + + if job.ID() != "job-123" { + t.Errorf("ID() = %s, want job-123", job.ID()) + } + if job.Name() != "test.job" { + t.Errorf("Name() = %s, want test.job", job.Name()) + } + if job.GroupID() != "pipe" { + t.Errorf("GroupID() = %s, want pipe", job.GroupID()) + } + if job.Priority() != 5 { + t.Errorf("Priority() = %d, want 5", job.Priority()) + } + if job.Delay() != 100 { + t.Errorf("Delay() = %d, want 100", job.Delay()) + } + if job.AutoAck() != true { + t.Error("AutoAck() = false, want true") + } + if string(job.Payload()) != `{"key":"value"}` { + t.Errorf("Payload() = %s", string(job.Payload())) + } + if job.Headers()["h1"][0] != "v1" { + t.Errorf("Headers() missing h1") + } + + // Kafka methods should return zero values + if job.Offset() != 0 { + t.Errorf("Offset() = %d, want 0", job.Offset()) + } + if job.Partition() != 0 { + t.Errorf("Partition() = %d, want 0", job.Partition()) + } + if job.Topic() != "" { + t.Errorf("Topic() = %s, want empty", job.Topic()) + } + if job.Metadata() != "" { + t.Errorf("Metadata() = %s, want empty", job.Metadata()) + } +} + +func TestJobInterfaceMethods_NilOptions(t *testing.T) { + job := &Job{ + Job: "test.job", + Ident: "job-nil", + } + + if job.GroupID() != "" { + t.Errorf("GroupID() should be empty with nil options, got %s", job.GroupID()) + } + if job.Priority() != 10 { + t.Errorf("Priority() should default to 10, got %d", job.Priority()) + } + if job.Delay() != 0 { + t.Errorf("Delay() should be 0 with nil options, got %d", job.Delay()) + } + if job.AutoAck() != false { + t.Error("AutoAck() should be false with nil options") + } +} + +func TestJobUpdatePriority(t *testing.T) { + job := &Job{Ident: "test"} + job.UpdatePriority(42) + + if job.Options == nil { + t.Fatal("Options should be created") + } + if job.Options.Priority != 42 { + t.Errorf("expected priority 42, got %d", job.Options.Priority) + } + + // Update again + job.UpdatePriority(1) + if job.Options.Priority != 1 { + t.Errorf("expected priority 1, got %d", job.Options.Priority) + } +} diff --git a/parser.go b/parser.go index 5fec9eb..698a3a0 100644 --- a/parser.go +++ b/parser.go @@ -24,8 +24,15 @@ func (s *Session) parseEmail(rawData []byte) (*ParsedMessage, error) { return nil, err } + // Capture all headers + headers := make(map[string][]string, len(msg.Header)) + for k, v := range msg.Header { + headers[k] = v + } + parsed := &ParsedMessage{ Raw: string(rawData), + Headers: headers, Sender: make([]EmailAddress, 0), Recipients: make([]EmailAddress, 0), CCs: make([]EmailAddress, 0), @@ -133,8 +140,31 @@ func (s *Session) processPartParsed(part *multipart.Part, parsed *ParsedMessage) return s.processAttachmentParsed(part, parsed) } + mediaType, params, _ := mime.ParseMediaType(contentType) + + // Handle nested multipart (e.g., multipart/alternative inside multipart/mixed) + if strings.HasPrefix(mediaType, "multipart/") { + boundary := params["boundary"] + if boundary != "" { + mr := multipart.NewReader(part, boundary) + for { + nestedPart, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + s.log.Error("nested multipart parse error", zap.Error(err)) + continue + } + if err := s.processPartParsed(nestedPart, parsed); err != nil { + s.log.Error("nested process part error", zap.Error(err)) + } + } + } + return nil + } + // This is body content - mediaType, _, _ := mime.ParseMediaType(contentType) if strings.HasPrefix(mediaType, "text/plain") || strings.HasPrefix(mediaType, "text/html") || contentType == "" { @@ -193,7 +223,8 @@ func (s *Session) processAttachmentParsed(part *multipart.Part, parsed *ParsedMe // Decode if base64 encoding := part.Header.Get("Content-Transfer-Encoding") if strings.EqualFold(encoding, "base64") { - decoded, err := base64.StdEncoding.DecodeString(string(content)) + cleaned := strings.NewReplacer("\r", "", "\n", "", " ", "").Replace(string(content)) + decoded, err := base64.StdEncoding.DecodeString(cleaned) if err == nil { content = decoded } @@ -202,6 +233,7 @@ func (s *Session) processAttachmentParsed(part *multipart.Part, parsed *ParsedMe attachment := Attachment{ Filename: filename, Type: contentType, + Size: int64(len(content)), } // Set ContentID if present @@ -257,7 +289,8 @@ func (s *Session) saveTempFile(content []byte, filename string) (string, error) func (s *Session) decodeContent(data []byte, encoding string) []byte { switch strings.ToLower(encoding) { case "base64": - decoded, err := base64.StdEncoding.DecodeString(string(data)) + cleaned := strings.NewReplacer("\r", "", "\n", "", " ", "").Replace(string(data)) + decoded, err := base64.StdEncoding.DecodeString(cleaned) if err != nil { return data } diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..461cc07 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,482 @@ +package smtp + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" +) + +func newTestSession(cfg *Config) *Session { + if cfg == nil { + cfg = &Config{ + AttachmentStorage: AttachmentConfig{Mode: "memory"}, + } + } + log, _ := zap.NewDevelopment() + p := &Plugin{cfg: cfg, log: log} + b := &Backend{plugin: p, log: log} + return &Session{ + backend: b, + uuid: "00000000-0000-0000-0000-000000000000", + to: []string{"recipient@test.com"}, + log: log, + } +} + +func TestParseEmail_SimplePlainText(t *testing.T) { + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: Hello\r\n\r\nThis is the body." + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.Subject != "Hello" { + t.Errorf("expected subject Hello, got %s", parsed.Subject) + } + if parsed.TextBody != "This is the body." { + t.Errorf("expected body 'This is the body.', got %q", parsed.TextBody) + } + if len(parsed.Sender) != 1 || parsed.Sender[0].Email != "sender@test.com" { + t.Errorf("unexpected sender: %+v", parsed.Sender) + } + if len(parsed.Recipients) != 1 || parsed.Recipients[0].Email != "recipient@test.com" { + t.Errorf("unexpected recipients: %+v", parsed.Recipients) + } +} + +func TestParseEmail_HTMLBody(t *testing.T) { + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: HTML\r\nContent-Type: text/html\r\n\r\n
HTML body
\r\n" + + "--boundary1\r\n" + + "Content-Disposition: attachment; filename=\"test.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "attachment content\r\n" + + "--boundary1--\r\n" + + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.TextBody != "Plain text body" { + t.Errorf("expected text body 'Plain text body', got %q", parsed.TextBody) + } + if parsed.HTMLBody != "HTML body
" { + t.Errorf("expected HTML body 'HTML body
', got %q", parsed.HTMLBody) + } + if len(parsed.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments)) + } + if parsed.Attachments[0].Filename != "test.txt" { + t.Errorf("expected filename test.txt, got %s", parsed.Attachments[0].Filename) + } + if parsed.Attachments[0].Size != int64(len("attachment content")) { + t.Errorf("expected attachment size %d, got %d", len("attachment content"), parsed.Attachments[0].Size) + } +} + +func TestParseEmail_NestedMultipart(t *testing.T) { + // multipart/mixed containing multipart/alternative + attachment + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Nested\r\n" + + "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 text\r\n" + + "--inner\r\n" + + "Content-Type: text/html\r\n\r\n" + + "HTML text
\r\n" + + "--inner--\r\n" + + "--outer\r\n" + + "Content-Disposition: attachment; filename=\"file.pdf\"\r\n" + + "Content-Type: application/pdf\r\n\r\n" + + "fake pdf content\r\n" + + "--outer--\r\n" + + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.TextBody != "Plain text" { + t.Errorf("expected nested text body 'Plain text', got %q", parsed.TextBody) + } + if parsed.HTMLBody != "HTML text
" { + t.Errorf("expected nested HTML body 'HTML text
', got %q", parsed.HTMLBody) + } + if len(parsed.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments)) + } + if parsed.Attachments[0].Filename != "file.pdf" { + t.Errorf("expected filename file.pdf, got %s", parsed.Attachments[0].Filename) + } +} + +func TestParseEmail_Base64Attachment(t *testing.T) { + content := "Hello, this is binary content!" + // Encode with line breaks like real email + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + var lines []string + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + lines = append(lines, encoded[i:end]) + } + encodedWithBreaks := strings.Join(lines, "\r\n") + + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Attachment\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "Body\r\n" + + "--b\r\n" + + "Content-Disposition: attachment; filename=\"data.bin\"\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + encodedWithBreaks + "\r\n" + + "--b--\r\n" + + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(parsed.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments)) + } + + att := parsed.Attachments[0] + // In memory mode, content is re-encoded as base64 (without line breaks) + decoded, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + t.Fatalf("failed to decode attachment content: %v", err) + } + if string(decoded) != content { + t.Errorf("expected attachment content %q, got %q", content, string(decoded)) + } + if att.Size != int64(len(content)) { + t.Errorf("expected size %d, got %d", len(content), att.Size) + } +} + +func TestParseEmail_TempfileAttachmentMode(t *testing.T) { + tmpDir := t.TempDir() + cfg := &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "tempfile", + TempDir: tmpDir, + }, + } + + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: TempFile\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "Body\r\n" + + "--b\r\n" + + "Content-Disposition: attachment; filename=\"doc.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "file content here\r\n" + + "--b--\r\n" + + s := newTestSession(cfg) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(parsed.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(parsed.Attachments)) + } + + att := parsed.Attachments[0] + // Content field should be a file path + if !strings.HasPrefix(att.Content, tmpDir) { + t.Errorf("expected temp file path under %s, got %s", tmpDir, att.Content) + } + + // Verify file exists and has correct content + data, err := os.ReadFile(att.Content) + if err != nil { + t.Fatalf("failed to read temp file: %v", err) + } + if string(data) != "file content here" { + t.Errorf("expected file content 'file content here', got %q", string(data)) + } +} + +func TestParseEmail_MessageID(t *testing.T) { + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: ID\r\nMessage-ID: