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

Hello

" + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.HTMLBody != "

Hello

" { + t.Errorf("expected HTML body, got %q", parsed.HTMLBody) + } + if parsed.TextBody != "" { + t.Errorf("expected empty text body, got %q", parsed.TextBody) + } +} + +func TestParseEmail_Base64BodyWithLineBreaks(t *testing.T) { + // Encode body as base64 with line breaks (RFC 2045 style, 76 chars per line) + body := "This is a test body that is long enough to require line breaks in base64 encoding format." + encoded := base64.StdEncoding.EncodeToString([]byte(body)) + // Insert line breaks every 76 chars like real email + 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 := fmt.Sprintf("From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: Base64\r\nContent-Transfer-Encoding: base64\r\n\r\n%s", encodedWithBreaks) + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.TextBody != body { + t.Errorf("expected decoded body %q, got %q", body, parsed.TextBody) + } +} + +func TestParseEmail_QuotedPrintable(t *testing.T) { + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: QP\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nHello =3D World" + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.TextBody != "Hello = World" { + t.Errorf("expected decoded QP body 'Hello = World', got %q", parsed.TextBody) + } +} + +func TestParseEmail_MultipartMixed(t *testing.T) { + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Multipart\r\n" + + "Content-Type: multipart/mixed; boundary=\"boundary1\"\r\n\r\n" + + "--boundary1\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "Plain text body\r\n" + + "--boundary1\r\n" + + "Content-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: \r\n\r\nBody" + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.ID == nil || *parsed.ID != "" { + t.Errorf("expected Message-ID , got %v", parsed.ID) + } +} + +func TestParseEmail_CCAndReplyTo(t *testing.T) { + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Cc: cc1@test.com, cc2@test.com\r\n" + + "Reply-To: reply@test.com\r\n" + + "Subject: CC\r\n\r\nBody" + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(parsed.CCs) != 2 { + t.Errorf("expected 2 CCs, got %d", len(parsed.CCs)) + } + if len(parsed.ReplyTo) != 1 || parsed.ReplyTo[0].Email != "reply@test.com" { + t.Errorf("unexpected Reply-To: %+v", parsed.ReplyTo) + } +} + +func TestParseEmail_HeadersPreserved(t *testing.T) { + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Headers\r\n" + + "X-Custom-Header: custom-value\r\n" + + "X-Mailer: TestMailer\r\n\r\nBody" + s := newTestSession(nil) + + parsed, err := s.parseEmail([]byte(raw)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if parsed.Headers == nil { + t.Fatal("headers should not be nil") + } + if v := parsed.Headers["X-Custom-Header"]; len(v) == 0 || v[0] != "custom-value" { + t.Errorf("expected X-Custom-Header: custom-value, got %v", v) + } + if v := parsed.Headers["X-Mailer"]; len(v) == 0 || v[0] != "TestMailer" { + t.Errorf("expected X-Mailer: TestMailer, got %v", v) + } +} + +func TestDecodeContent_Base64WithLineBreaks(t *testing.T) { + s := newTestSession(nil) + + original := "Hello World! This is a test string." + encoded := base64.StdEncoding.EncodeToString([]byte(original)) + // Add line breaks + withBreaks := encoded[:10] + "\r\n" + encoded[10:] + + decoded := s.decodeContent([]byte(withBreaks), "base64") + if string(decoded) != original { + t.Errorf("expected %q, got %q", original, string(decoded)) + } +} + +func TestDecodeContent_QuotedPrintable(t *testing.T) { + s := newTestSession(nil) + + decoded := s.decodeContent([]byte("Hello=20World"), "quoted-printable") + if string(decoded) != "Hello World" { + t.Errorf("expected 'Hello World', got %q", string(decoded)) + } +} + +func TestDecodeContent_NoEncoding(t *testing.T) { + s := newTestSession(nil) + + input := []byte("plain text") + decoded := s.decodeContent(input, "") + if string(decoded) != "plain text" { + t.Errorf("expected 'plain text', got %q", string(decoded)) + } + + decoded = s.decodeContent(input, "7bit") + if string(decoded) != "plain text" { + t.Errorf("expected 'plain text' for 7bit, got %q", string(decoded)) + } +} + +func TestSaveTempFile(t *testing.T) { + tmpDir := t.TempDir() + cfg := &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "tempfile", + TempDir: tmpDir, + }, + } + s := newTestSession(cfg) + + content := []byte("test file content") + path, err := s.saveTempFile(content, "test.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.HasPrefix(filepath.Base(path), "smtp-att-") { + t.Errorf("temp file should start with smtp-att-, got %s", filepath.Base(path)) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read temp file: %v", err) + } + if string(data) != "test file content" { + t.Errorf("expected 'test file content', got %q", string(data)) + } +} + +func TestParseEmail_UnnamedAttachment(t *testing.T) { + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Unnamed\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\r\n" + + "Content-Type: application/octet-stream\r\n\r\n" + + "binary data\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)) + } + if parsed.Attachments[0].Filename != "unnamed" { + t.Errorf("expected filename 'unnamed', got %s", parsed.Attachments[0].Filename) + } +} + +func TestParseEmail_ContentIDOnInline(t *testing.T) { + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Inline\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: inline; filename=\"image.png\"\r\n" + + "Content-Type: image/png\r\n" + + "Content-ID: \r\n\r\n" + + "fake png data\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] + if att.ContentID == nil || *att.ContentID != "img001" { + t.Errorf("expected ContentID 'img001', got %v", att.ContentID) + } +} diff --git a/plugin.go b/plugin.go index 4088f62..c5353de 100644 --- a/plugin.go +++ b/plugin.go @@ -41,6 +41,9 @@ type Plugin struct { // SMTP server components smtpServer *smtp.Server listener net.Listener + + // Cleanup routine cancellation + cleanupCancel context.CancelFunc } // Init initializes the plugin with configuration and logger @@ -128,7 +131,9 @@ func (p *Plugin) Serve() chan error { }() // 5. Start temp file cleanup routine - p.startCleanupRoutine(context.Background()) + cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) + p.cleanupCancel = cleanupCancel + p.startCleanupRoutine(cleanupCtx) return errCh } @@ -143,19 +148,28 @@ func (p *Plugin) Stop(ctx context.Context) error { p.mu.Lock() defer p.mu.Unlock() - // 1. Close listener (stops accepting new connections) + // 1. Stop cleanup routine + if p.cleanupCancel != nil { + p.cleanupCancel() + } + + // 2. Close listener (stops accepting new connections) if p.listener != nil { _ = p.listener.Close() } - // 2. Close SMTP server + // 3. Close SMTP server if p.smtpServer != nil { _ = p.smtpServer.Close() } - // 3. Close all tracked connections + // 4. Close all tracked connections p.connections.Range(func(key, value any) bool { - // Sessions will be cleaned up by Logout() + session := value.(*Session) + if session.conn != nil && session.conn.Conn() != nil { + _ = session.conn.Conn().Close() + } + p.connections.Delete(key) return true }) @@ -201,10 +215,13 @@ func (p *Plugin) pushToJobs(email *EmailData) error { } // Convert to domain model - msg := emailToJobMessage(email, &p.cfg.Jobs) + msg, err := emailToJobMessage(email, &p.cfg.Jobs) + if err != nil { + return errors.E(op, err) + } // Push directly to Jobs plugin - err := p.jobs.Push(context.Background(), msg) + err = p.jobs.Push(context.Background(), msg) if err != nil { return errors.E(op, err) } diff --git a/rpc.go b/rpc.go index ab47da9..47a1718 100644 --- a/rpc.go +++ b/rpc.go @@ -30,12 +30,13 @@ func (r *rpc) CloseConnection(uuid string, success *bool) error { session := value.(*Session) - // Close underlying connection + // Mark session as closing (Logout will handle map cleanup) + session.shouldClose = true + + // Close underlying connection — triggers Logout() which deletes from map if session.conn != nil && session.conn.Conn() != nil { _ = session.conn.Conn().Close() } - - r.p.connections.Delete(uuid) *success = true return nil diff --git a/rpc_test.go b/rpc_test.go new file mode 100644 index 0000000..790c54b --- /dev/null +++ b/rpc_test.go @@ -0,0 +1,91 @@ +package smtp + +import ( + "testing" + + "go.uber.org/zap" +) + +func TestListConnections_Empty(t *testing.T) { + log, _ := zap.NewDevelopment() + p := &Plugin{log: log} + r := &rpc{p: p} + + var conns []ConnectionInfo + err := r.ListConnections(false, &conns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conns) != 0 { + t.Errorf("expected 0 connections, got %d", len(conns)) + } +} + +func TestListConnections_WithSessions(t *testing.T) { + log, _ := zap.NewDevelopment() + p := &Plugin{log: log} + r := &rpc{p: p} + + // Add sessions + s1 := &Session{ + uuid: "uuid-1", + remoteAddr: "10.0.0.1:1234", + from: "a@test.com", + to: []string{"b@test.com"}, + authenticated: true, + authUsername: "user1", + } + s2 := &Session{ + uuid: "uuid-2", + remoteAddr: "10.0.0.2:5678", + from: "c@test.com", + to: []string{"d@test.com", "e@test.com"}, + } + + p.connections.Store("uuid-1", s1) + p.connections.Store("uuid-2", s2) + + var conns []ConnectionInfo + err := r.ListConnections(false, &conns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conns) != 2 { + t.Fatalf("expected 2 connections, got %d", len(conns)) + } + + // Find uuid-1 + var found bool + for _, c := range conns { + if c.UUID == "uuid-1" { + found = true + if c.RemoteAddr != "10.0.0.1:1234" { + t.Errorf("expected remote addr 10.0.0.1:1234, got %s", c.RemoteAddr) + } + if !c.Authenticated { + t.Error("expected authenticated = true") + } + if c.Username != "user1" { + t.Errorf("expected username user1, got %s", c.Username) + } + } + } + if !found { + t.Error("uuid-1 not found in connections list") + } +} + +func TestCloseConnection_NotFound(t *testing.T) { + log, _ := zap.NewDevelopment() + p := &Plugin{log: log} + r := &rpc{p: p} + + var success bool + err := r.CloseConnection("nonexistent", &success) + if err == nil { + t.Error("expected error for nonexistent connection") + } + if success { + t.Error("success should be false") + } +} diff --git a/session.go b/session.go index 2a64ac0..bd30ca2 100644 --- a/session.go +++ b/session.go @@ -100,13 +100,20 @@ func (s *Session) Data(r io.Reader) error { } // Convert attachments + cfg := s.backend.plugin.cfg attachments := make([]AttachmentData, 0, len(parsedMessage.Attachments)) for _, att := range parsedMessage.Attachments { - attachments = append(attachments, AttachmentData{ + ad := AttachmentData{ Filename: att.Filename, ContentType: att.Type, - Content: att.Content, - }) + Size: att.Size, + } + if cfg.AttachmentStorage.Mode == "tempfile" { + ad.Path = att.Content + } else { + ad.Content = att.Content + } + attachments = append(attachments, ad) } emailData := &EmailData{ @@ -124,10 +131,8 @@ func (s *Session) Data(r io.Reader) error { }, Auth: authData, Message: MessageData{ - Id: parsedMessage.ID, - Headers: map[string][]string{ - "Subject": {parsedMessage.Subject}, - }, + Id: parsedMessage.ID, + Headers: parsedMessage.Headers, Body: parsedMessage.TextBody, HTMLBody: parsedMessage.HTMLBody, Raw: parsedMessage.Raw, diff --git a/session_test.go b/session_test.go new file mode 100644 index 0000000..3479a30 --- /dev/null +++ b/session_test.go @@ -0,0 +1,301 @@ +package smtp + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/roadrunner-server/api/v4/plugins/v4/jobs" + "go.uber.org/zap" +) + +// capturingJobs captures pushed messages for inspection +type capturingJobs struct { + messages []jobs.Message +} + +func (c *capturingJobs) Push(_ context.Context, msg jobs.Message) error { + c.messages = append(c.messages, msg) + return nil +} + +func newTestPlugin(mode string) (*Plugin, *capturingJobs) { + log, _ := zap.NewDevelopment() + capture := &capturingJobs{} + cfg := &Config{ + AttachmentStorage: AttachmentConfig{Mode: mode}, + Jobs: JobsConfig{Pipeline: "test", Priority: 10}, + } + p := &Plugin{ + cfg: cfg, + log: log, + jobs: capture, + } + return p, capture +} + +func newTestSessionWithPlugin(p *Plugin) *Session { + b := &Backend{plugin: p, log: p.log} + return &Session{ + backend: b, + uuid: "sess-00000000", + remoteAddr: "127.0.0.1:9999", + from: "sender@test.com", + to: []string{"recipient@test.com"}, + log: p.log, + } +} + +func TestSession_DataFlow(t *testing.T) { + p, capture := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: Integration\r\n\r\nHello from test" + err := s.Data(strings.NewReader(raw)) + if err != nil { + t.Fatalf("Data() error: %v", err) + } + + if len(capture.messages) != 1 { + t.Fatalf("expected 1 pushed message, got %d", len(capture.messages)) + } + + msg := capture.messages[0] + var email EmailData + if err := json.Unmarshal(msg.Payload(), &email); err != nil { + t.Fatalf("failed to unmarshal payload: %v", err) + } + + if email.Event != "EMAIL_RECEIVED" { + t.Errorf("expected event EMAIL_RECEIVED, got %s", email.Event) + } + if email.UUID != "sess-00000000" { + t.Errorf("expected UUID sess-00000000, got %s", email.UUID) + } + if email.RemoteAddr != "127.0.0.1:9999" { + t.Errorf("expected remote addr 127.0.0.1:9999, got %s", email.RemoteAddr) + } + if email.Message.Subject != "Integration" { + t.Errorf("expected subject Integration, got %s", email.Message.Subject) + } + if email.Message.Body != "Hello from test" { + t.Errorf("expected body 'Hello from test', got %q", email.Message.Body) + } + if len(email.Envelope.From) != 1 || email.Envelope.From[0].Email != "sender@test.com" { + t.Errorf("unexpected envelope from: %+v", email.Envelope.From) + } +} + +func TestSession_DataWithAttachment_MemoryMode(t *testing.T) { + p, capture := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Att\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=\"test.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "attachment data\r\n" + + "--b--\r\n" + + err := s.Data(strings.NewReader(raw)) + if err != nil { + t.Fatalf("Data() error: %v", err) + } + + var email EmailData + json.Unmarshal(capture.messages[0].Payload(), &email) + + if len(email.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(email.Attachments)) + } + + att := email.Attachments[0] + if att.Filename != "test.txt" { + t.Errorf("expected filename test.txt, got %s", att.Filename) + } + if att.Content == "" { + t.Error("expected Content to be set in memory mode") + } + if att.Path != "" { + t.Error("expected Path to be empty in memory mode") + } +} + +func TestSession_DataWithAttachment_TempfileMode(t *testing.T) { + tmpDir := t.TempDir() + log, _ := zap.NewDevelopment() + capture := &capturingJobs{} + cfg := &Config{ + AttachmentStorage: AttachmentConfig{ + Mode: "tempfile", + TempDir: tmpDir, + }, + Jobs: JobsConfig{Pipeline: "test", Priority: 10}, + } + p := &Plugin{cfg: cfg, log: log, jobs: capture} + s := newTestSessionWithPlugin(p) + + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: TempAtt\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.pdf\"\r\n" + + "Content-Type: application/pdf\r\n\r\n" + + "fake pdf\r\n" + + "--b--\r\n" + + err := s.Data(strings.NewReader(raw)) + if err != nil { + t.Fatalf("Data() error: %v", err) + } + + var email EmailData + json.Unmarshal(capture.messages[0].Payload(), &email) + + att := email.Attachments[0] + if att.Path == "" { + t.Error("expected Path to be set in tempfile mode") + } + if att.Content != "" { + t.Error("expected Content to be empty in tempfile mode") + } + if !strings.HasPrefix(att.Path, tmpDir) { + t.Errorf("expected path under %s, got %s", tmpDir, att.Path) + } +} + +func TestSession_DataWithAuth(t *testing.T) { + p, capture := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + s.authenticated = true + s.authUsername = "user@test.com" + s.authPassword = "secret" + s.authMechanism = "PLAIN" + + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: Auth\r\n\r\nBody" + err := s.Data(strings.NewReader(raw)) + if err != nil { + t.Fatalf("Data() error: %v", err) + } + + var email EmailData + json.Unmarshal(capture.messages[0].Payload(), &email) + + if email.Auth == nil { + t.Fatal("expected auth data") + } + if !email.Auth.Attempted { + t.Error("expected auth attempted = true") + } + if email.Auth.Username != "user@test.com" { + t.Errorf("expected username user@test.com, got %s", email.Auth.Username) + } + if email.Auth.Mechanism != "PLAIN" { + t.Errorf("expected mechanism PLAIN, got %s", email.Auth.Mechanism) + } +} + +func TestSession_DataWithoutAuth(t *testing.T) { + p, capture := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + + raw := "From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: NoAuth\r\n\r\nBody" + s.Data(strings.NewReader(raw)) + + var email EmailData + json.Unmarshal(capture.messages[0].Payload(), &email) + + if email.Auth != nil { + t.Error("expected nil auth when not authenticated") + } +} + +func TestSession_HeadersPassedThrough(t *testing.T) { + p, capture := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + + raw := "From: sender@test.com\r\n" + + "To: recipient@test.com\r\n" + + "Subject: Headers\r\n" + + "X-Priority: 1\r\n" + + "Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\r\nBody" + + s.Data(strings.NewReader(raw)) + + var email EmailData + json.Unmarshal(capture.messages[0].Payload(), &email) + + if email.Message.Headers == nil { + t.Fatal("headers should not be nil") + } + if v, ok := email.Message.Headers["X-Priority"]; !ok || v[0] != "1" { + t.Errorf("expected X-Priority header, got %v", email.Message.Headers["X-Priority"]) + } + if _, ok := email.Message.Headers["Date"]; !ok { + t.Error("expected Date header to be preserved") + } +} + +func TestSession_MailAndRcpt(t *testing.T) { + p, _ := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + + s.Mail("from@test.com", nil) + if s.from != "from@test.com" { + t.Errorf("expected from from@test.com, got %s", s.from) + } + + s.Rcpt("to1@test.com", nil) + s.Rcpt("to2@test.com", nil) + if len(s.to) != 3 { // 1 from newTestSessionWithPlugin + 2 added + t.Errorf("expected 3 recipients, got %d", len(s.to)) + } +} + +func TestSession_Reset(t *testing.T) { + p, _ := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + s.emailData = *bytes.NewBufferString("some data") + + s.Reset() + + if s.from != "" { + t.Error("from should be empty after reset") + } + if s.to != nil { + t.Error("to should be nil after reset") + } + if s.emailData.Len() != 0 { + t.Error("emailData should be empty after reset") + } +} + +func TestSession_Logout(t *testing.T) { + p, _ := newTestPlugin("memory") + s := newTestSessionWithPlugin(p) + + // Store session in connections map + p.connections.Store(s.uuid, s) + + err := s.Logout() + if err != nil { + t.Fatalf("Logout() error: %v", err) + } + + // Should be removed from connections + if _, ok := p.connections.Load(s.uuid); ok { + t.Error("session should be removed from connections after Logout") + } +} diff --git a/types.go b/types.go index d1550ac..a69b5a3 100644 --- a/types.go +++ b/types.go @@ -62,14 +62,16 @@ type Attachment struct { Filename string `json:"filename"` Content string `json:"content"` Type string `json:"type"` + Size int64 `json:"size"` ContentID *string `json:"contentId"` } // ParsedMessage represents the structure expected by PHP Parser type ParsedMessage struct { - ID *string `json:"id"` - Raw string `json:"raw"` - Sender []EmailAddress `json:"sender"` + ID *string `json:"id"` + Raw string `json:"raw"` + Headers map[string][]string `json:"headers"` + Sender []EmailAddress `json:"sender"` Recipients []EmailAddress `json:"recipients"` CCs []EmailAddress `json:"ccs"` Subject string `json:"subject"`