diff --git a/.gitignore b/.gitignore index 6e9f78e..e709fad 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ # .DS_Store files .DS_Store .gocache +raven-test diff --git a/internal/auth/oauthbearer/oauthbearer_test.go b/internal/auth/oauthbearer/oauthbearer_test.go index f05f829..c4cdbea 100644 --- a/internal/auth/oauthbearer/oauthbearer_test.go +++ b/internal/auth/oauthbearer/oauthbearer_test.go @@ -420,15 +420,15 @@ func TestEvaluateRoleAccess_RejectsUnsafeComponents(t *testing.T) { // characters that must never make it into the mailbox identity, even // when the token roles claim happens to match. cases := map[string]string{ - "path traversal in domain": "admin@..", - "double dot inside domain": "admin@co..com", - "slash in domain": "admin@co/com", - "backslash in domain": "admin@co\\com", - "slash in local": "ad/min@co.com", - "double dot in local": "ad..min@co.com", - "non-ascii in domain": "admin@cö.com", - "whitespace inside local": "ad min@co.com", - "null byte in domain": "admin@co.com\x00evil", + "path traversal in domain": "admin@..", + "double dot inside domain": "admin@co..com", + "slash in domain": "admin@co/com", + "backslash in domain": "admin@co\\com", + "slash in local": "ad/min@co.com", + "double dot in local": "ad..min@co.com", + "non-ascii in domain": "admin@cö.com", + "whitespace inside local": "ad min@co.com", + "null byte in domain": "admin@co.com\x00evil", } for name, addr := range cases { t.Run(name, func(t *testing.T) { diff --git a/internal/blobstorage/s3.go b/internal/blobstorage/s3.go index 09ceabd..69e33ba 100644 --- a/internal/blobstorage/s3.go +++ b/internal/blobstorage/s3.go @@ -45,7 +45,7 @@ type Config struct { AccessKey string `yaml:"access_key"` // #nosec G117 -- Configuration field name, not a hardcoded secret SecretKey string `yaml:"secret_key"` - Timeout int `yaml:"timeout"` // seconds + Timeout int `yaml:"timeout"` // seconds } // NewS3BlobStorage creates a new S3 blob storage instance diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 91c400a..6f9258f 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -444,8 +444,6 @@ func UserExists(db *sql.DB, username string, domainID int64) (bool, error) { return count > 0, err } - - // Mailbox management functions func CreateMailbox(db *sql.DB, userID int64, name string, specialUse string) (int64, error) { @@ -1248,6 +1246,7 @@ func GetMessageHeaders(db *sql.DB, messageID int64) ([]map[string]string, error) return headers, rows.Err() } + // createDefaultMailboxes creates default mailboxes for a new user. // Kept here with other schema helpers so migrations and initialization stay together. func createDefaultMailboxes(db *sql.DB) error { diff --git a/internal/db/sqlite_test.go b/internal/db/sqlite_test.go index edda1ea..fb0d6ca 100644 --- a/internal/db/sqlite_test.go +++ b/internal/db/sqlite_test.go @@ -1569,4 +1569,3 @@ func TestGetMessageHeaders(t *testing.T) { } } } - diff --git a/internal/delivery/groupresolver/groupresolver.go b/internal/delivery/groupresolver/groupresolver.go index a87ddb9..8f1bfd1 100644 --- a/internal/delivery/groupresolver/groupresolver.go +++ b/internal/delivery/groupresolver/groupresolver.go @@ -534,4 +534,4 @@ func extractJWTExpiry(token string) (time.Time, error) { } return time.Unix(claims.Exp, 0), nil -} \ No newline at end of file +} diff --git a/internal/delivery/groupresolver/groupresolver_test.go b/internal/delivery/groupresolver/groupresolver_test.go index df2be68..b6e8e9d 100644 --- a/internal/delivery/groupresolver/groupresolver_test.go +++ b/internal/delivery/groupresolver/groupresolver_test.go @@ -176,8 +176,8 @@ func TestGroupMemberResolution(t *testing.T) { case "/users/user-1": resp := map[string]interface{}{ - "id": "user-1", - "ouId": "ou-1", + "id": "user-1", + "ouId": "ou-1", "attributes": map[string]string{ "username": "alice", }, @@ -186,8 +186,8 @@ func TestGroupMemberResolution(t *testing.T) { case "/users/user-2": resp := map[string]interface{}{ - "id": "user-2", - "ouId": "ou-2", + "id": "user-2", + "ouId": "ou-2", "attributes": map[string]string{ "username": "bob", }, @@ -290,4 +290,4 @@ func TestGroupNotFound(t *testing.T) { if !strings.Contains(err.Error(), "not found") { t.Errorf("ResolveGroupMembers() error = %v, expected 'not found'", err) } -} \ No newline at end of file +} diff --git a/internal/delivery/lmtp/server.go b/internal/delivery/lmtp/server.go index 0eb8eef..d2c53cd 100644 --- a/internal/delivery/lmtp/server.go +++ b/internal/delivery/lmtp/server.go @@ -282,4 +282,4 @@ func (s *Server) Shutdown() error { log.Println("LMTP server shutdown complete") return nil -} \ No newline at end of file +} diff --git a/internal/delivery/storage/storage_test.go b/internal/delivery/storage/storage_test.go index 8a91014..04b404b 100644 --- a/internal/delivery/storage/storage_test.go +++ b/internal/delivery/storage/storage_test.go @@ -12,799 +12,799 @@ import ( // helper to create a temp DBManager func setupTestDBManager(t *testing.T) *db.DBManager { - t.Helper() - dir, err := os.MkdirTemp("", "storage_test_*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - manager, err := db.NewDBManager(dir) - if err != nil { - t.Fatalf("failed to create db manager: %v", err) - } - t.Cleanup(func() { _ = manager.Close() }) - return manager + t.Helper() + dir, err := os.MkdirTemp("", "storage_test_*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + manager, err := db.NewDBManager(dir) + if err != nil { + t.Fatalf("failed to create db manager: %v", err) + } + t.Cleanup(func() { _ = manager.Close() }) + return manager } func sampleRawMessage(from string, to []string, subject string, body string) string { - return "From: " + from + "\r\n" + - "To: " + to[0] + "\r\n" + - "Subject: " + subject + "\r\n" + - "Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" + - "Message-ID: \r\n" + - "Content-Type: text/plain; charset=us-ascii\r\n" + - "\r\n" + body + "\r\n" + return "From: " + from + "\r\n" + + "To: " + to[0] + "\r\n" + + "Subject: " + subject + "\r\n" + + "Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" + + "Message-ID: \r\n" + + "Content-Type: text/plain; charset=us-ascii\r\n" + + "\r\n" + body + "\r\n" } func buildParserMessage(from string, to []string, subject string, body string) *parser.Message { - raw := sampleRawMessage(from, to, subject, body) - return &parser.Message{ - From: from, - To: to, - Subject: subject, - Date: time.Now(), - MessageID: "", - Headers: map[string]string{"From": from, "To": to[0], "Subject": subject}, - Body: body, - RawMessage: raw, - Size: int64(len(raw)), - } + raw := sampleRawMessage(from, to, subject, body) + return &parser.Message{ + From: from, + To: to, + Subject: subject, + Date: time.Now(), + MessageID: "", + Headers: map[string]string{"From": from, "To": to[0], "Subject": subject}, + Body: body, + RawMessage: raw, + Size: int64(len(raw)), + } } func TestDeliverMessage_NewUserAndMailbox(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - msg := buildParserMessage("sender@example.com", []string{"user1@example.com"}, "Test", "Hello") - if err := stor.DeliverMessage("user1@example.com", msg, "INBOX"); err != nil { - t.Fatalf("DeliverMessage failed: %v", err) - } - - // Message count for user should be 1 - count, err := stor.GetMessageCount("user1@example.com") - if err != nil { - t.Fatalf("GetMessageCount failed: %v", err) - } - if count != 1 { - t.Errorf("expected 1 message, got %d", count) - } - - folderCount, err := stor.GetMessageCountInFolder("user1@example.com", "INBOX") - if err != nil { - t.Fatalf("GetMessageCountInFolder failed: %v", err) - } - if folderCount != 1 { - t.Errorf("expected 1 message in INBOX, got %d", folderCount) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + msg := buildParserMessage("sender@example.com", []string{"user1@example.com"}, "Test", "Hello") + if err := stor.DeliverMessage("user1@example.com", msg, "INBOX"); err != nil { + t.Fatalf("DeliverMessage failed: %v", err) + } + + // Message count for user should be 1 + count, err := stor.GetMessageCount("user1@example.com") + if err != nil { + t.Fatalf("GetMessageCount failed: %v", err) + } + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } + + folderCount, err := stor.GetMessageCountInFolder("user1@example.com", "INBOX") + if err != nil { + t.Fatalf("GetMessageCountInFolder failed: %v", err) + } + if folderCount != 1 { + t.Errorf("expected 1 message in INBOX, got %d", folderCount) + } } func TestDeliverToMultipleRecipients(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"usera@example.com"}, "Multi", "Hi") - - recipients := []string{"usera@example.com", "invalid-no-at", "userb@example.com"} - results := stor.DeliverToMultipleRecipients(recipients, msg, "INBOX") - - if results["usera@example.com"] != nil { - t.Errorf("expected usera delivery ok: %v", results["usera@example.com"]) - } - if results["userb@example.com"] != nil { - t.Errorf("expected userb delivery ok: %v", results["userb@example.com"]) - } - if results["invalid-no-at"] == nil { - t.Errorf("expected invalid recipient to have error") - } - - // verify counts for users - countA, _ := stor.GetMessageCount("usera@example.com") - countB, _ := stor.GetMessageCount("userb@example.com") - if countA != 1 || countB != 1 { - t.Errorf("expected one message for each valid user, got %d and %d", countA, countB) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + msg := buildParserMessage("sender@example.com", []string{"usera@example.com"}, "Multi", "Hi") + + recipients := []string{"usera@example.com", "invalid-no-at", "userb@example.com"} + results := stor.DeliverToMultipleRecipients(recipients, msg, "INBOX") + + if results["usera@example.com"] != nil { + t.Errorf("expected usera delivery ok: %v", results["usera@example.com"]) + } + if results["userb@example.com"] != nil { + t.Errorf("expected userb delivery ok: %v", results["userb@example.com"]) + } + if results["invalid-no-at"] == nil { + t.Errorf("expected invalid recipient to have error") + } + + // verify counts for users + countA, _ := stor.GetMessageCount("usera@example.com") + countB, _ := stor.GetMessageCount("userb@example.com") + if countA != 1 || countB != 1 { + t.Errorf("expected one message for each valid user, got %d and %d", countA, countB) + } } func TestCheckUserExists(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - exists, err := stor.CheckUserExists("nouser") - if err != nil || !exists { - t.Errorf("expected user existence to be true: %v %v", exists, err) - } + exists, err := stor.CheckUserExists("nouser") + if err != nil || !exists { + t.Errorf("expected user existence to be true: %v %v", exists, err) + } } func TestQuotaFunctions(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"quotauser@example.com"}, "Quota", strings.Repeat("A", 50)) - if err := stor.DeliverMessage("quotauser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + msg := buildParserMessage("sender@example.com", []string{"quotauser@example.com"}, "Quota", strings.Repeat("A", 50)) + if err := stor.DeliverMessage("quotauser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - usage, err := stor.GetUserQuota("quotauser@example.com") - if err != nil { - t.Fatalf("GetUserQuota failed: %v", err) - } - if usage <= 0 { - t.Errorf("expected usage > 0, got %d", usage) - } + usage, err := stor.GetUserQuota("quotauser@example.com") + if err != nil { + t.Fatalf("GetUserQuota failed: %v", err) + } + if usage <= 0 { + t.Errorf("expected usage > 0, got %d", usage) + } - // CheckQuota should succeed for large limit - if err := stor.CheckQuota("quotauser@example.com", 10, usage+1000); err != nil { - t.Errorf("unexpected quota failure: %v", err) - } + // CheckQuota should succeed for large limit + if err := stor.CheckQuota("quotauser@example.com", 10, usage+1000); err != nil { + t.Errorf("unexpected quota failure: %v", err) + } - // Exceed quota - if err := stor.CheckQuota("quotauser@example.com", 10, usage-1); err == nil { - t.Errorf("expected quota exceeded error") - } + // Exceed quota + if err := stor.CheckQuota("quotauser@example.com", 10, usage-1); err == nil { + t.Errorf("expected quota exceeded error") + } } func TestGetMessageCountInFolder_MissingFolder(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // No messages delivered yet - count, err := stor.GetMessageCountInFolder("nobody@example.com", "INBOX") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if count != 0 { - t.Errorf("expected 0 count for missing user/folder, got %d", count) - } + // No messages delivered yet + count, err := stor.GetMessageCountInFolder("nobody@example.com", "INBOX") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 0 { + t.Errorf("expected 0 count for missing user/folder, got %d", count) + } } func TestCreateUserIfNotExists_WithDomainInName(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - if err := stor.CreateUserIfNotExists("domainuser@example.net"); err != nil { - t.Fatalf("CreateUserIfNotExists failed: %v", err) - } + if err := stor.CreateUserIfNotExists("domainuser@example.net"); err != nil { + t.Fatalf("CreateUserIfNotExists failed: %v", err) + } - count, err := stor.GetMessageCount("domainuser@example.net") - if err != nil { - t.Fatalf("GetMessageCount failed: %v", err) - } - if count != 0 { - t.Errorf("expected 0 messages for newly created user, got %d", count) - } + count, err := stor.GetMessageCount("domainuser@example.net") + if err != nil { + t.Fatalf("GetMessageCount failed: %v", err) + } + if count != 0 { + t.Errorf("expected 0 messages for newly created user, got %d", count) + } } func TestDeliverMessage_InvalidRecipient(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"bad"}, "Bad", "Body") - // Should fail because recipient lacks domain - if err := stor.DeliverMessage("bad", msg, "INBOX"); err == nil { - t.Errorf("expected error for invalid recipient") - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + msg := buildParserMessage("sender@example.com", []string{"bad"}, "Bad", "Body") + // Should fail because recipient lacks domain + if err := stor.DeliverMessage("bad", msg, "INBOX"); err == nil { + t.Errorf("expected error for invalid recipient") + } } func TestDeliverMessage_CustomFolder(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"custom@example.com"}, "Custom Folder Test", "Body") - if err := stor.DeliverMessage("custom@example.com", msg, "CustomFolder"); err != nil { - t.Fatalf("DeliverMessage to custom folder failed: %v", err) - } + msg := buildParserMessage("sender@example.com", []string{"custom@example.com"}, "Custom Folder Test", "Body") + if err := stor.DeliverMessage("custom@example.com", msg, "CustomFolder"); err != nil { + t.Fatalf("DeliverMessage to custom folder failed: %v", err) + } - count, err := stor.GetMessageCountInFolder("custom@example.com", "CustomFolder") - if err != nil { - t.Fatalf("GetMessageCountInFolder failed: %v", err) - } - if count != 1 { - t.Errorf("expected 1 message in CustomFolder, got %d", count) - } + count, err := stor.GetMessageCountInFolder("custom@example.com", "CustomFolder") + if err != nil { + t.Fatalf("GetMessageCountInFolder failed: %v", err) + } + if count != 1 { + t.Errorf("expected 1 message in CustomFolder, got %d", count) + } } func TestDeliverMessage_MultipleDeliveries(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - // Deliver multiple messages to same user - for i := 0; i < 5; i++ { - msg := buildParserMessage("sender@example.com", []string{"multi@example.com"}, - "Message "+string(rune('0'+i)), "Body "+string(rune('0'+i))) - if err := stor.DeliverMessage("multi@example.com", msg, "INBOX"); err != nil { - t.Fatalf("DeliverMessage %d failed: %v", i, err) - } - } - - count, err := stor.GetMessageCount("multi@example.com") - if err != nil { - t.Fatalf("GetMessageCount failed: %v", err) - } - if count != 5 { - t.Errorf("expected 5 messages, got %d", count) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + // Deliver multiple messages to same user + for i := 0; i < 5; i++ { + msg := buildParserMessage("sender@example.com", []string{"multi@example.com"}, + "Message "+string(rune('0'+i)), "Body "+string(rune('0'+i))) + if err := stor.DeliverMessage("multi@example.com", msg, "INBOX"); err != nil { + t.Fatalf("DeliverMessage %d failed: %v", i, err) + } + } + + count, err := stor.GetMessageCount("multi@example.com") + if err != nil { + t.Fatalf("GetMessageCount failed: %v", err) + } + if count != 5 { + t.Errorf("expected 5 messages, got %d", count) + } } func TestGetUserQuota_NonExistentUser(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - usage, err := stor.GetUserQuota("nonexistent@example.com") - if err != nil { - t.Fatalf("GetUserQuota should not error for non-existent user: %v", err) - } - if usage != 0 { - t.Errorf("expected 0 usage for non-existent user, got %d", usage) - } + usage, err := stor.GetUserQuota("nonexistent@example.com") + if err != nil { + t.Fatalf("GetUserQuota should not error for non-existent user: %v", err) + } + if usage != 0 { + t.Errorf("expected 0 usage for non-existent user, got %d", usage) + } } func TestGetMessageCount_NonExistentUser(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - count, err := stor.GetMessageCount("nonexistent@example.com") - if err != nil { - t.Fatalf("GetMessageCount should not error for non-existent user: %v", err) - } - if count != 0 { - t.Errorf("expected 0 count for non-existent user, got %d", count) - } + count, err := stor.GetMessageCount("nonexistent@example.com") + if err != nil { + t.Fatalf("GetMessageCount should not error for non-existent user: %v", err) + } + if count != 0 { + t.Errorf("expected 0 count for non-existent user, got %d", count) + } } func TestCreateUserIfNotExists_WithoutDomain(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // User without @ defaults to localhost domain - if err := stor.CreateUserIfNotExists("localuser"); err != nil { - t.Fatalf("CreateUserIfNotExists failed: %v", err) - } + // User without @ defaults to localhost domain + if err := stor.CreateUserIfNotExists("localuser"); err != nil { + t.Fatalf("CreateUserIfNotExists failed: %v", err) + } - exists, err := stor.CheckUserExists("localuser") - if err != nil || !exists { - t.Errorf("expected created user to exist: %v %v", exists, err) - } + exists, err := stor.CheckUserExists("localuser") + if err != nil || !exists { + t.Errorf("expected created user to exist: %v %v", exists, err) + } } func TestDeliverMessage_ToExistingFolder(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - // First delivery creates the folder - msg1 := buildParserMessage("sender@example.com", []string{"foldertest@example.com"}, "First", "Body1") - if err := stor.DeliverMessage("foldertest@example.com", msg1, "TestFolder"); err != nil { - t.Fatalf("First delivery failed: %v", err) - } - - // Second delivery to same folder - msg2 := buildParserMessage("sender@example.com", []string{"foldertest@example.com"}, "Second", "Body2") - if err := stor.DeliverMessage("foldertest@example.com", msg2, "TestFolder"); err != nil { - t.Fatalf("Second delivery failed: %v", err) - } - - count, err := stor.GetMessageCountInFolder("foldertest@example.com", "TestFolder") - if err != nil { - t.Fatalf("GetMessageCountInFolder failed: %v", err) - } - if count != 2 { - t.Errorf("expected 2 messages in TestFolder, got %d", count) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + // First delivery creates the folder + msg1 := buildParserMessage("sender@example.com", []string{"foldertest@example.com"}, "First", "Body1") + if err := stor.DeliverMessage("foldertest@example.com", msg1, "TestFolder"); err != nil { + t.Fatalf("First delivery failed: %v", err) + } + + // Second delivery to same folder + msg2 := buildParserMessage("sender@example.com", []string{"foldertest@example.com"}, "Second", "Body2") + if err := stor.DeliverMessage("foldertest@example.com", msg2, "TestFolder"); err != nil { + t.Fatalf("Second delivery failed: %v", err) + } + + count, err := stor.GetMessageCountInFolder("foldertest@example.com", "TestFolder") + if err != nil { + t.Fatalf("GetMessageCountInFolder failed: %v", err) + } + if count != 2 { + t.Errorf("expected 2 messages in TestFolder, got %d", count) + } } func TestDeliverToMultipleRecipients_AllValid(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"user1@example.com"}, "All Valid", "Body") + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + msg := buildParserMessage("sender@example.com", []string{"user1@example.com"}, "All Valid", "Body") - recipients := []string{"user1@example.com", "user2@example.com", "user3@example.com"} - results := stor.DeliverToMultipleRecipients(recipients, msg, "INBOX") + recipients := []string{"user1@example.com", "user2@example.com", "user3@example.com"} + results := stor.DeliverToMultipleRecipients(recipients, msg, "INBOX") - for _, recipient := range recipients { - if results[recipient] != nil { - t.Errorf("expected delivery to %s to succeed: %v", recipient, results[recipient]) - } - } + for _, recipient := range recipients { + if results[recipient] != nil { + t.Errorf("expected delivery to %s to succeed: %v", recipient, results[recipient]) + } + } - // Verify all users got the message - for i, recipient := range recipients { - count, _ := stor.GetMessageCount(recipient) - if count != 1 { - t.Errorf("user%d should have 1 message, got %d", i+1, count) - } - } + // Verify all users got the message + for i, recipient := range recipients { + count, _ := stor.GetMessageCount(recipient) + if count != 1 { + t.Errorf("user%d should have 1 message, got %d", i+1, count) + } + } } func TestCheckQuota_ExactLimit(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"quotatest@example.com"}, "Quota", "Body") - if err := stor.DeliverMessage("quotatest@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + msg := buildParserMessage("sender@example.com", []string{"quotatest@example.com"}, "Quota", "Body") + if err := stor.DeliverMessage("quotatest@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - usage, _ := stor.GetUserQuota("quotatest@example.com") + usage, _ := stor.GetUserQuota("quotatest@example.com") - // Exactly at limit should succeed - if err := stor.CheckQuota("quotatest@example.com", 0, usage); err != nil { - t.Errorf("quota check at exact limit should succeed: %v", err) - } + // Exactly at limit should succeed + if err := stor.CheckQuota("quotatest@example.com", 0, usage); err != nil { + t.Errorf("quota check at exact limit should succeed: %v", err) + } } func TestDeliverMessage_LargeBody(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // Create a large message body (>1024 bytes to test blob storage) - largeBody := strings.Repeat("This is a large message body. ", 50) - msg := buildParserMessage("sender@example.com", []string{"largeuser@example.com"}, "Large", largeBody) - - if err := stor.DeliverMessage("largeuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver large message failed: %v", err) - } + // Create a large message body (>1024 bytes to test blob storage) + largeBody := strings.Repeat("This is a large message body. ", 50) + msg := buildParserMessage("sender@example.com", []string{"largeuser@example.com"}, "Large", largeBody) - count, _ := stor.GetMessageCount("largeuser@example.com") - if count != 1 { - t.Errorf("expected 1 message, got %d", count) - } + if err := stor.DeliverMessage("largeuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver large message failed: %v", err) + } - usage, _ := stor.GetUserQuota("largeuser@example.com") - if usage <= 0 { - t.Errorf("expected usage > 0 for large message") - } + count, _ := stor.GetMessageCount("largeuser@example.com") + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } + + usage, _ := stor.GetUserQuota("largeuser@example.com") + if usage <= 0 { + t.Errorf("expected usage > 0 for large message") + } } func TestDeliverMessage_MultipleFolders(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - // Deliver to different folders for same user - folders := []string{"INBOX", "Sent", "Drafts", "Archive"} - for _, folder := range folders { - msg := buildParserMessage("sender@example.com", []string{"multifolderuser@example.com"}, - "Folder: "+folder, "Body") - if err := stor.DeliverMessage("multifolderuser@example.com", msg, folder); err != nil { - t.Fatalf("deliver to %s failed: %v", folder, err) - } - } - - totalCount, _ := stor.GetMessageCount("multifolderuser@example.com") - if totalCount != 4 { - t.Errorf("expected 4 total messages, got %d", totalCount) - } - - for _, folder := range folders { - count, _ := stor.GetMessageCountInFolder("multifolderuser@example.com", folder) - if count != 1 { - t.Errorf("expected 1 message in %s, got %d", folder, count) - } - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + // Deliver to different folders for same user + folders := []string{"INBOX", "Sent", "Drafts", "Archive"} + for _, folder := range folders { + msg := buildParserMessage("sender@example.com", []string{"multifolderuser@example.com"}, + "Folder: "+folder, "Body") + if err := stor.DeliverMessage("multifolderuser@example.com", msg, folder); err != nil { + t.Fatalf("deliver to %s failed: %v", folder, err) + } + } + + totalCount, _ := stor.GetMessageCount("multifolderuser@example.com") + if totalCount != 4 { + t.Errorf("expected 4 total messages, got %d", totalCount) + } + + for _, folder := range folders { + count, _ := stor.GetMessageCountInFolder("multifolderuser@example.com", folder) + if count != 1 { + t.Errorf("expected 1 message in %s, got %d", folder, count) + } + } } func TestGetMessageCountInFolder_AfterDelivery(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // Initial count should be 0 - count1, _ := stor.GetMessageCountInFolder("countuser@example.com", "INBOX") - if count1 != 0 { - t.Errorf("expected 0 initial messages, got %d", count1) - } + // Initial count should be 0 + count1, _ := stor.GetMessageCountInFolder("countuser@example.com", "INBOX") + if count1 != 0 { + t.Errorf("expected 0 initial messages, got %d", count1) + } - // Deliver message - msg := buildParserMessage("sender@example.com", []string{"countuser@example.com"}, "Count Test", "Body") - if err := stor.DeliverMessage("countuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + // Deliver message + msg := buildParserMessage("sender@example.com", []string{"countuser@example.com"}, "Count Test", "Body") + if err := stor.DeliverMessage("countuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - // Count should now be 1 - count2, _ := stor.GetMessageCountInFolder("countuser@example.com", "INBOX") - if count2 != 1 { - t.Errorf("expected 1 message after delivery, got %d", count2) - } + // Count should now be 1 + count2, _ := stor.GetMessageCountInFolder("countuser@example.com", "INBOX") + if count2 != 1 { + t.Errorf("expected 1 message after delivery, got %d", count2) + } } func TestDeliverMessage_MultipartMessage(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - // Create a multipart message - boundary := "boundary123" - rawMsg := "From: sender@example.com\r\n" + - "To: multipart@example.com\r\n" + - "Subject: Multipart Test\r\n" + - "Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" + - "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n" + - "\r\n" + - "--" + boundary + "\r\n" + - "Content-Type: text/plain; charset=utf-8\r\n" + - "\r\n" + - "Plain text part\r\n" + - "--" + boundary + "\r\n" + - "Content-Type: text/html; charset=utf-8\r\n" + - "\r\n" + - "HTML part\r\n" + - "--" + boundary + "--\r\n" - - msg := &parser.Message{ - From: "sender@example.com", - To: []string{"multipart@example.com"}, - Subject: "Multipart Test", - Date: time.Now(), - MessageID: "", - Headers: map[string]string{"From": "sender@example.com", "To": "multipart@example.com", "Subject": "Multipart Test"}, - Body: "Plain text part", - RawMessage: rawMsg, - Size: int64(len(rawMsg)), - } - - if err := stor.DeliverMessage("multipart@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver multipart message failed: %v", err) - } - - count, _ := stor.GetMessageCount("multipart@example.com") - if count != 1 { - t.Errorf("expected 1 message, got %d", count) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + // Create a multipart message + boundary := "boundary123" + rawMsg := "From: sender@example.com\r\n" + + "To: multipart@example.com\r\n" + + "Subject: Multipart Test\r\n" + + "Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" + + "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n" + + "\r\n" + + "--" + boundary + "\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "Plain text part\r\n" + + "--" + boundary + "\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + "HTML part\r\n" + + "--" + boundary + "--\r\n" + + msg := &parser.Message{ + From: "sender@example.com", + To: []string{"multipart@example.com"}, + Subject: "Multipart Test", + Date: time.Now(), + MessageID: "", + Headers: map[string]string{"From": "sender@example.com", "To": "multipart@example.com", "Subject": "Multipart Test"}, + Body: "Plain text part", + RawMessage: rawMsg, + Size: int64(len(rawMsg)), + } + + if err := stor.DeliverMessage("multipart@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver multipart message failed: %v", err) + } + + count, _ := stor.GetMessageCount("multipart@example.com") + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } } func TestDeliverToMultipleRecipients_PartialFailure(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"partial@example.com"}, "Partial", "Body") + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + msg := buildParserMessage("sender@example.com", []string{"partial@example.com"}, "Partial", "Body") - recipients := []string{"good1@example.com", "good2@example.com", "also-bad"} - results := stor.DeliverToMultipleRecipients(recipients, msg, "INBOX") + recipients := []string{"good1@example.com", "good2@example.com", "also-bad"} + results := stor.DeliverToMultipleRecipients(recipients, msg, "INBOX") - // Check successful deliveries - if results["good1@example.com"] != nil { - t.Errorf("expected good1 delivery to succeed: %v", results["good1@example.com"]) - } - if results["good2@example.com"] != nil { - t.Errorf("expected good2 delivery to succeed: %v", results["good2@example.com"]) - } + // Check successful deliveries + if results["good1@example.com"] != nil { + t.Errorf("expected good1 delivery to succeed: %v", results["good1@example.com"]) + } + if results["good2@example.com"] != nil { + t.Errorf("expected good2 delivery to succeed: %v", results["good2@example.com"]) + } - // Check failed delivery (no @ sign) - if results["also-bad"] == nil { - t.Error("expected also-bad delivery to fail") - } + // Check failed delivery (no @ sign) + if results["also-bad"] == nil { + t.Error("expected also-bad delivery to fail") + } } func TestDeliverMessage_EmptyBody(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"empty@example.com"}, "Empty Body", "") - if err := stor.DeliverMessage("empty@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver empty body message failed: %v", err) - } + msg := buildParserMessage("sender@example.com", []string{"empty@example.com"}, "Empty Body", "") + if err := stor.DeliverMessage("empty@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver empty body message failed: %v", err) + } - count, _ := stor.GetMessageCount("empty@example.com") - if count != 1 { - t.Errorf("expected 1 message, got %d", count) - } + count, _ := stor.GetMessageCount("empty@example.com") + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } } func TestDeliverMessage_SpecialCharactersInSubject(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - specialSubject := "Test: Special & \"Quotes\" [Brackets]" - msg := buildParserMessage("sender@example.com", []string{"special@example.com"}, specialSubject, "Body") - if err := stor.DeliverMessage("special@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver message with special chars failed: %v", err) - } + specialSubject := "Test: Special & \"Quotes\" [Brackets]" + msg := buildParserMessage("sender@example.com", []string{"special@example.com"}, specialSubject, "Body") + if err := stor.DeliverMessage("special@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver message with special chars failed: %v", err) + } - count, _ := stor.GetMessageCount("special@example.com") - if count != 1 { - t.Errorf("expected 1 message, got %d", count) - } + count, _ := stor.GetMessageCount("special@example.com") + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } } func TestCreateUserIfNotExists_MultipleCalls(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // First call creates user - if err := stor.CreateUserIfNotExists("idempotent@example.com"); err != nil { - t.Fatalf("first CreateUserIfNotExists failed: %v", err) - } + // First call creates user + if err := stor.CreateUserIfNotExists("idempotent@example.com"); err != nil { + t.Fatalf("first CreateUserIfNotExists failed: %v", err) + } - // Second call should succeed (idempotent) - if err := stor.CreateUserIfNotExists("idempotent@example.com"); err != nil { - t.Fatalf("second CreateUserIfNotExists failed: %v", err) - } + // Second call should succeed (idempotent) + if err := stor.CreateUserIfNotExists("idempotent@example.com"); err != nil { + t.Fatalf("second CreateUserIfNotExists failed: %v", err) + } - exists, _ := stor.CheckUserExists("idempotent") - if !exists { - t.Error("expected user to exist") - } + exists, _ := stor.CheckUserExists("idempotent") + if !exists { + t.Error("expected user to exist") + } } func TestDeliverMessage_WithDifferentDomains(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - // Deliver to different domains - domains := []string{"domain1.com", "domain2.com", "domain3.org"} - for _, domain := range domains { - recipient := "user@" + domain - msg := buildParserMessage("sender@example.com", []string{recipient}, "Domain Test", "Body") - if err := stor.DeliverMessage(recipient, msg, "INBOX"); err != nil { - t.Fatalf("deliver to %s failed: %v", domain, err) - } - } - - // Verify all deliveries - for _, domain := range domains { - recipient := "user@" + domain - count, _ := stor.GetMessageCount(recipient) - if count != 1 { - t.Errorf("expected 1 message for %s, got %d", recipient, count) - } - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + // Deliver to different domains + domains := []string{"domain1.com", "domain2.com", "domain3.org"} + for _, domain := range domains { + recipient := "user@" + domain + msg := buildParserMessage("sender@example.com", []string{recipient}, "Domain Test", "Body") + if err := stor.DeliverMessage(recipient, msg, "INBOX"); err != nil { + t.Fatalf("deliver to %s failed: %v", domain, err) + } + } + + // Verify all deliveries + for _, domain := range domains { + recipient := "user@" + domain + count, _ := stor.GetMessageCount(recipient) + if count != 1 { + t.Errorf("expected 1 message for %s, got %d", recipient, count) + } + } } func TestGetUserQuota_AfterMultipleDeliveries(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - recipient := "quotamulti@example.com" - initialUsage, _ := stor.GetUserQuota("quotamulti@example.com") + recipient := "quotamulti@example.com" + initialUsage, _ := stor.GetUserQuota("quotamulti@example.com") - // Deliver multiple messages - for i := 0; i < 3; i++ { - body := strings.Repeat("X", 100+i*10) - msg := buildParserMessage("sender@example.com", []string{recipient}, "Quota Test", body) - if err := stor.DeliverMessage(recipient, msg, "INBOX"); err != nil { - t.Fatalf("delivery %d failed: %v", i, err) - } - } + // Deliver multiple messages + for i := 0; i < 3; i++ { + body := strings.Repeat("X", 100+i*10) + msg := buildParserMessage("sender@example.com", []string{recipient}, "Quota Test", body) + if err := stor.DeliverMessage(recipient, msg, "INBOX"); err != nil { + t.Fatalf("delivery %d failed: %v", i, err) + } + } - finalUsage, _ := stor.GetUserQuota("quotamulti@example.com") - if finalUsage <= initialUsage { - t.Errorf("expected quota to increase after deliveries: initial=%d, final=%d", initialUsage, finalUsage) - } + finalUsage, _ := stor.GetUserQuota("quotamulti@example.com") + if finalUsage <= initialUsage { + t.Errorf("expected quota to increase after deliveries: initial=%d, final=%d", initialUsage, finalUsage) + } } func TestGetMessageCountInFolder_NonExistentFolder(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // Create user but not the folder - msg := buildParserMessage("sender@example.com", []string{"folderuser@example.com"}, "Test", "Body") - if err := stor.DeliverMessage("folderuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + // Create user but not the folder + msg := buildParserMessage("sender@example.com", []string{"folderuser@example.com"}, "Test", "Body") + if err := stor.DeliverMessage("folderuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - // Check non-existent folder - count, err := stor.GetMessageCountInFolder("folderuser@example.com", "NonExistent") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if count != 0 { - t.Errorf("expected 0 count for non-existent folder, got %d", count) - } + // Check non-existent folder + count, err := stor.GetMessageCountInFolder("folderuser@example.com", "NonExistent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 0 { + t.Errorf("expected 0 count for non-existent folder, got %d", count) + } } func TestCheckUserExists_WithDifferentCases(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - // Create user - msg := buildParserMessage("sender@example.com", []string{"testuser@example.com"}, "Test", "Body") - if err := stor.DeliverMessage("testuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + // Create user + msg := buildParserMessage("sender@example.com", []string{"testuser@example.com"}, "Test", "Body") + if err := stor.DeliverMessage("testuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - // Check with exact case - exists, _ := stor.CheckUserExists("testuser") - if !exists { - t.Error("expected testuser to exist") - } + // Check with exact case + exists, _ := stor.CheckUserExists("testuser") + if !exists { + t.Error("expected testuser to exist") + } } func TestDeliverMessage_WithAttachment(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - // Create message with attachment-like content - boundary := "attach123" - rawMsg := "From: sender@example.com\r\n" + - "To: attach@example.com\r\n" + - "Subject: With Attachment\r\n" + - "Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" + - "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n" + - "\r\n" + - "--" + boundary + "\r\n" + - "Content-Type: text/plain\r\n" + - "\r\n" + - "Message body\r\n" + - "--" + boundary + "\r\n" + - "Content-Type: application/octet-stream\r\n" + - "Content-Disposition: attachment; filename=\"file.bin\"\r\n" + - "\r\n" + - "Binary data here\r\n" + - "--" + boundary + "--\r\n" - - msg := &parser.Message{ - From: "sender@example.com", - To: []string{"attach@example.com"}, - Subject: "With Attachment", - Date: time.Now(), - MessageID: "", - Headers: map[string]string{}, - Body: "Message body", - RawMessage: rawMsg, - Size: int64(len(rawMsg)), - } - - if err := stor.DeliverMessage("attach@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver with attachment failed: %v", err) - } - - count, _ := stor.GetMessageCount("attach@example.com") - if count != 1 { - t.Errorf("expected 1 message, got %d", count) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + // Create message with attachment-like content + boundary := "attach123" + rawMsg := "From: sender@example.com\r\n" + + "To: attach@example.com\r\n" + + "Subject: With Attachment\r\n" + + "Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" + + "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"\r\n" + + "\r\n" + + "--" + boundary + "\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "Message body\r\n" + + "--" + boundary + "\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Disposition: attachment; filename=\"file.bin\"\r\n" + + "\r\n" + + "Binary data here\r\n" + + "--" + boundary + "--\r\n" + + msg := &parser.Message{ + From: "sender@example.com", + To: []string{"attach@example.com"}, + Subject: "With Attachment", + Date: time.Now(), + MessageID: "", + Headers: map[string]string{}, + Body: "Message body", + RawMessage: rawMsg, + Size: int64(len(rawMsg)), + } + + if err := stor.DeliverMessage("attach@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver with attachment failed: %v", err) + } + + count, _ := stor.GetMessageCount("attach@example.com") + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } } // Spam filtering tests func TestSpamFiltering_RspamdActionReject(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"spamuser@example.com"}, "Spam Test", "Spam body") - msg.Headers["X-Rspamd-Action"] = "reject" + msg := buildParserMessage("sender@example.com", []string{"spamuser@example.com"}, "Spam Test", "Spam body") + msg.Headers["X-Rspamd-Action"] = "reject" - if err := stor.DeliverMessage("spamuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("spamuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - // Should be in Spam folder, not INBOX - spamCount, _ := stor.GetMessageCountInFolder("spamuser@example.com", "Spam") - if spamCount != 1 { - t.Errorf("expected 1 message in Spam folder, got %d", spamCount) - } + // Should be in Spam folder, not INBOX + spamCount, _ := stor.GetMessageCountInFolder("spamuser@example.com", "Spam") + if spamCount != 1 { + t.Errorf("expected 1 message in Spam folder, got %d", spamCount) + } - inboxCount, _ := stor.GetMessageCountInFolder("spamuser@example.com", "INBOX") - if inboxCount != 0 { - t.Errorf("expected 0 messages in INBOX, got %d", inboxCount) - } + inboxCount, _ := stor.GetMessageCountInFolder("spamuser@example.com", "INBOX") + if inboxCount != 0 { + t.Errorf("expected 0 messages in INBOX, got %d", inboxCount) + } } func TestSpamFiltering_RspamdActionAddHeader(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"spamuser2@example.com"}, "Spam Test", "Spam body") - msg.Headers["X-Rspamd-Action"] = "add header" + msg := buildParserMessage("sender@example.com", []string{"spamuser2@example.com"}, "Spam Test", "Spam body") + msg.Headers["X-Rspamd-Action"] = "add header" - if err := stor.DeliverMessage("spamuser2@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("spamuser2@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - spamCount, _ := stor.GetMessageCountInFolder("spamuser2@example.com", "Spam") - if spamCount != 1 { - t.Errorf("expected 1 message in Spam folder, got %d", spamCount) - } + spamCount, _ := stor.GetMessageCountInFolder("spamuser2@example.com", "Spam") + if spamCount != 1 { + t.Errorf("expected 1 message in Spam folder, got %d", spamCount) + } } func TestSpamFiltering_RspamdActionRewriteSubject(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"spamuser3@example.com"}, "Spam Test", "Spam body") - msg.Headers["X-Rspamd-Action"] = "rewrite subject" + msg := buildParserMessage("sender@example.com", []string{"spamuser3@example.com"}, "Spam Test", "Spam body") + msg.Headers["X-Rspamd-Action"] = "rewrite subject" - if err := stor.DeliverMessage("spamuser3@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("spamuser3@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - spamCount, _ := stor.GetMessageCountInFolder("spamuser3@example.com", "Spam") - if spamCount != 1 { - t.Errorf("expected 1 message in Spam folder, got %d", spamCount) - } + spamCount, _ := stor.GetMessageCountInFolder("spamuser3@example.com", "Spam") + if spamCount != 1 { + t.Errorf("expected 1 message in Spam folder, got %d", spamCount) + } } func TestSpamFiltering_XSpamStatusYes(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"spamuser4@example.com"}, "Spam Test", "Spam body") - msg.Headers["X-Spam-Status"] = "Yes, score=10.5" + msg := buildParserMessage("sender@example.com", []string{"spamuser4@example.com"}, "Spam Test", "Spam body") + msg.Headers["X-Spam-Status"] = "Yes, score=10.5" - if err := stor.DeliverMessage("spamuser4@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("spamuser4@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - spamCount, _ := stor.GetMessageCountInFolder("spamuser4@example.com", "Spam") - if spamCount != 1 { - t.Errorf("expected 1 message in Spam folder, got %d", spamCount) - } + spamCount, _ := stor.GetMessageCountInFolder("spamuser4@example.com", "Spam") + if spamCount != 1 { + t.Errorf("expected 1 message in Spam folder, got %d", spamCount) + } } func TestSpamFiltering_NoActionGoesToInbox(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"hamuser@example.com"}, "Ham Test", "Ham body") - msg.Headers["X-Rspamd-Action"] = "no action" - msg.Headers["X-Spam-Status"] = "No, score=-1.10" + msg := buildParserMessage("sender@example.com", []string{"hamuser@example.com"}, "Ham Test", "Ham body") + msg.Headers["X-Rspamd-Action"] = "no action" + msg.Headers["X-Spam-Status"] = "No, score=-1.10" - if err := stor.DeliverMessage("hamuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("hamuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - inboxCount, _ := stor.GetMessageCountInFolder("hamuser@example.com", "INBOX") - if inboxCount != 1 { - t.Errorf("expected 1 message in INBOX, got %d", inboxCount) - } + inboxCount, _ := stor.GetMessageCountInFolder("hamuser@example.com", "INBOX") + if inboxCount != 1 { + t.Errorf("expected 1 message in INBOX, got %d", inboxCount) + } - spamCount, _ := stor.GetMessageCountInFolder("hamuser@example.com", "Spam") - if spamCount != 0 { - t.Errorf("expected 0 messages in Spam folder, got %d", spamCount) - } + spamCount, _ := stor.GetMessageCountInFolder("hamuser@example.com", "Spam") + if spamCount != 0 { + t.Errorf("expected 0 messages in Spam folder, got %d", spamCount) + } } func TestSpamFiltering_NoHeadersGoesToInbox(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"cleanuser@example.com"}, "Clean Test", "Clean body") - // No Rspamd headers at all + msg := buildParserMessage("sender@example.com", []string{"cleanuser@example.com"}, "Clean Test", "Clean body") + // No Rspamd headers at all - if err := stor.DeliverMessage("cleanuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("cleanuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - inboxCount, _ := stor.GetMessageCountInFolder("cleanuser@example.com", "INBOX") - if inboxCount != 1 { - t.Errorf("expected 1 message in INBOX, got %d", inboxCount) - } + inboxCount, _ := stor.GetMessageCountInFolder("cleanuser@example.com", "INBOX") + if inboxCount != 1 { + t.Errorf("expected 1 message in INBOX, got %d", inboxCount) + } - spamCount, _ := stor.GetMessageCountInFolder("cleanuser@example.com", "Spam") - if spamCount != 0 { - t.Errorf("expected 0 messages in Spam folder, got %d", spamCount) - } + spamCount, _ := stor.GetMessageCountInFolder("cleanuser@example.com", "Spam") + if spamCount != 0 { + t.Errorf("expected 0 messages in Spam folder, got %d", spamCount) + } } func TestSpamFiltering_CaseInsensitive(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) - msg := buildParserMessage("sender@example.com", []string{"caseuser@example.com"}, "Case Test", "Body") - msg.Headers["X-Rspamd-Action"] = "REJECT" // Uppercase - msg.Headers["X-Spam-Status"] = "YES, score=5.0" // Uppercase + msg := buildParserMessage("sender@example.com", []string{"caseuser@example.com"}, "Case Test", "Body") + msg.Headers["X-Rspamd-Action"] = "REJECT" // Uppercase + msg.Headers["X-Spam-Status"] = "YES, score=5.0" // Uppercase - if err := stor.DeliverMessage("caseuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } + if err := stor.DeliverMessage("caseuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } - spamCount, _ := stor.GetMessageCountInFolder("caseuser@example.com", "Spam") - if spamCount != 1 { - t.Errorf("expected 1 message in Spam folder (case-insensitive matching), got %d", spamCount) - } + spamCount, _ := stor.GetMessageCountInFolder("caseuser@example.com", "Spam") + if spamCount != 1 { + t.Errorf("expected 1 message in Spam folder (case-insensitive matching), got %d", spamCount) + } } func TestSpamFiltering_GreylistGoesToInbox(t *testing.T) { - mgr := setupTestDBManager(t) - stor := NewStorage(mgr) - - msg := buildParserMessage("sender@example.com", []string{"greyuser@example.com"}, "Greylist Test", "Body") - msg.Headers["X-Rspamd-Action"] = "greylist" - - if err := stor.DeliverMessage("greyuser@example.com", msg, "INBOX"); err != nil { - t.Fatalf("deliver failed: %v", err) - } - - // Greylist action should go to INBOX, not Spam - inboxCount, _ := stor.GetMessageCountInFolder("greyuser@example.com", "INBOX") - if inboxCount != 1 { - t.Errorf("expected 1 message in INBOX for greylist, got %d", inboxCount) - } - - spamCount, _ := stor.GetMessageCountInFolder("greyuser@example.com", "Spam") - if spamCount != 0 { - t.Errorf("expected 0 messages in Spam folder for greylist, got %d", spamCount) - } + mgr := setupTestDBManager(t) + stor := NewStorage(mgr) + + msg := buildParserMessage("sender@example.com", []string{"greyuser@example.com"}, "Greylist Test", "Body") + msg.Headers["X-Rspamd-Action"] = "greylist" + + if err := stor.DeliverMessage("greyuser@example.com", msg, "INBOX"); err != nil { + t.Fatalf("deliver failed: %v", err) + } + + // Greylist action should go to INBOX, not Spam + inboxCount, _ := stor.GetMessageCountInFolder("greyuser@example.com", "INBOX") + if inboxCount != 1 { + t.Errorf("expected 1 message in INBOX for greylist, got %d", inboxCount) + } + + spamCount, _ := stor.GetMessageCountInFolder("greyuser@example.com", "Spam") + if spamCount != 0 { + t.Errorf("expected 0 messages in Spam folder for greylist, got %d", spamCount) + } } diff --git a/internal/models/state.go b/internal/models/state.go index 2da0d54..64d316e 100644 --- a/internal/models/state.go +++ b/internal/models/state.go @@ -3,17 +3,17 @@ package models import "net" type ClientState struct { - Authenticated bool - SelectedFolder string - SelectedMailboxID int64 // Database ID of selected mailbox - Conn net.Conn - Username string // Local part of the email address (before @) - Email string // Full email address from IDP (username@domain) - UserID int64 // Deprecated: kept for backward-compatible tests - DomainID int64 // Deprecated: kept for backward-compatible tests + Authenticated bool + SelectedFolder string + SelectedMailboxID int64 // Database ID of selected mailbox + Conn net.Conn + Username string // Local part of the email address (before @) + Email string // Full email address from IDP (username@domain) + UserID int64 // Deprecated: kept for backward-compatible tests + DomainID int64 // Deprecated: kept for backward-compatible tests // Mailbox state tracking for NOOP and other commands - LastMessageCount int // Last known message count in selected folder - LastRecentCount int // Last known recent (unseen) message count - UIDValidity int64 // UID validity for selected mailbox - UIDNext int64 // Next UID for selected mailbox + LastMessageCount int // Last known message count in selected folder + LastRecentCount int // Last known recent (unseen) message count + UIDValidity int64 // UID validity for selected mailbox + UIDNext int64 // Next UID for selected mailbox } diff --git a/internal/sasl/server.go b/internal/sasl/server.go index 076f21e..a9dcfdd 100644 --- a/internal/sasl/server.go +++ b/internal/sasl/server.go @@ -26,23 +26,33 @@ const ( ConnectionTypeTCP ConnectionType = iota // ConnectionTypeUnixSocket represents a Unix domain socket connection ConnectionTypeUnixSocket + + // maxAuthStatesPerConn limits concurrent auth attempts per connection to prevent DoS + maxAuthStatesPerConn = 10 ) +// authState tracks multi-step authentication state +type authState struct { + Mechanism string + Step int + Username string +} + // Server represents a SASL authentication server type Server struct { - socketPath string - tcpAddr string - authURL string - domain string - saslScope conf.SASLScope - oauthConfig *conf.Config + socketPath string + tcpAddr string + authURL string + domain string + saslScope conf.SASLScope + oauthConfig *conf.Config oauthValidator *oauthbearer.Validator - unixListener net.Listener - tcpListener net.Listener - mu sync.Mutex - wg sync.WaitGroup - shutdown chan struct{} - shutdownOnce sync.Once + unixListener net.Listener + tcpListener net.Listener + mu sync.Mutex + wg sync.WaitGroup + shutdown chan struct{} + shutdownOnce sync.Once } // NewServer creates a new SASL authentication server @@ -261,6 +271,7 @@ func (s *Server) handleConnection(conn net.Conn, connType ConnectionType) { defer func() { _ = conn.Close() }() scanner := bufio.NewScanner(conn) + authStates := make(map[string]*authState) // Set read deadline to prevent hanging connections _ = conn.SetReadDeadline(time.Now().Add(30 * time.Second)) @@ -316,7 +327,10 @@ func (s *Server) handleConnection(conn net.Conn, connType ConnectionType) { log.Printf("SASL sent: %s", strings.TrimSpace(response)) case "AUTH": - s.handleAuth(conn, parts) + s.handleAuth(conn, parts, authStates) + + case "CONT": + s.handleCont(conn, parts, authStates) default: // Sanitize command for logging to prevent log injection @@ -335,7 +349,7 @@ func (s *Server) handleConnection(conn net.Conn, connType ConnectionType) { } // handleAuth handles authentication requests -func (s *Server) handleAuth(conn net.Conn, parts []string) { +func (s *Server) handleAuth(conn net.Conn, parts []string, authStates map[string]*authState) { // AUTH format: AUTH\t\t\t[service=]\t[resp=] // Example: AUTH 1 PLAIN service=smtp resp=AHRlc3RAdGVzdC5jb20AdGVzdDEyMw== @@ -363,13 +377,21 @@ func (s *Server) handleAuth(conn net.Conn, parts []string) { log.Printf("Service: %s, Response present: %v", service, respProvided) + // Limit concurrent auth attempts per connection (DoS protection) + if len(authStates) >= maxAuthStatesPerConn { + response := fmt.Sprintf("FAIL\t%s\treason=Too many authentication attempts\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + return + } + switch strings.ToUpper(mechanism) { case "PLAIN": - s.handlePlain(conn, id, resp, respProvided) + s.handlePlain(conn, id, resp, respProvided, authStates) case "LOGIN": - s.handleLogin(conn, id, resp) + s.handleLogin(conn, id, resp, authStates) case "OAUTHBEARER", "XOAUTH2": - s.handleOAuthBearer(conn, id, resp, respProvided) + s.handleOAuthBearer(conn, id, resp, respProvided, strings.ToUpper(mechanism), authStates) default: // Unsupported mechanism response := fmt.Sprintf("FAIL\t%s\treason=Unsupported mechanism\n", id) @@ -379,9 +401,10 @@ func (s *Server) handleAuth(conn net.Conn, parts []string) { } // handlePlain handles PLAIN authentication mechanism -func (s *Server) handlePlain(conn net.Conn, id, resp string, respProvided bool) { +func (s *Server) handlePlain(conn net.Conn, id, resp string, respProvided bool, authStates map[string]*authState) { // If no response provided, request it if !respProvided { + authStates[id] = &authState{Mechanism: "PLAIN", Step: 1} response := fmt.Sprintf("CONT\t%s\t\n", id) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) @@ -445,31 +468,40 @@ func (s *Server) handlePlain(conn net.Conn, id, resp string, respProvided bool) } // handleLogin handles LOGIN authentication mechanism -func (s *Server) handleLogin(conn net.Conn, id, resp string) { - // LOGIN is a two-step process - // Step 1: Request username - // Step 2: Request password - - // For simplicity, we'll treat it similar to PLAIN for now - // In a full implementation, you'd need to maintain state between requests - +func (s *Server) handleLogin(conn net.Conn, id, resp string, authStates map[string]*authState) { if resp == "" { // Request username + authStates[id] = &authState{Mechanism: "LOGIN", Step: 1} response := fmt.Sprintf("CONT\t%s\tUsername:\n", id) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) return } - // This is a simplified implementation - // A full LOGIN implementation would require state management - response := fmt.Sprintf("FAIL\t%s\treason=LOGIN not fully implemented, use PLAIN\n", id) + // If response was provided with AUTH command (initial response for username) + decoded, err := base64.StdEncoding.DecodeString(resp) + if err != nil { + log.Printf("Failed to decode base64 response: %v", err) + response := fmt.Sprintf("FAIL\t%s\treason=Invalid encoding\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + return + } + + authStates[id] = &authState{ + Mechanism: "LOGIN", + Step: 2, + Username: string(decoded), + } + + response := fmt.Sprintf("CONT\t%s\tPassword:\n", id) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) } -func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided bool) { +func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided bool, mechanism string, authStates map[string]*authState) { if !respProvided { + authStates[id] = &authState{Mechanism: mechanism, Step: 1} response := fmt.Sprintf("CONT\t%s\t\n", id) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) @@ -618,3 +650,101 @@ func (s *Server) authenticate(username, password string) bool { log.Printf("Authentication API returned status %d for user: %s", resp.StatusCode, authUsername) return false } + +// handleCont handles continuation requests +func (s *Server) handleCont(conn net.Conn, parts []string, authStates map[string]*authState) { + // CONT format: CONT + if len(parts) < 3 { + log.Printf("Invalid CONT command format, parts: %d", len(parts)) + id := "" + if len(parts) >= 2 { + id = parts[1] + } + response := fmt.Sprintf("FAIL\t%s\treason=Invalid command format\n", id) + _, _ = conn.Write([]byte(response)) + return + } + + id := parts[1] + resp := parts[2] + + state, ok := authStates[id] + if !ok { + response := fmt.Sprintf("FAIL\t%s\treason=No active authentication flow\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + return + } + + switch state.Mechanism { + case "PLAIN": + delete(authStates, id) + s.handlePlain(conn, id, resp, true, authStates) + case "LOGIN": + s.handleLoginCont(conn, id, resp, state, authStates) + case "OAUTHBEARER", "XOAUTH2": + mechanism := state.Mechanism + delete(authStates, id) + s.handleOAuthBearer(conn, id, resp, true, mechanism, authStates) + default: + delete(authStates, id) + response := fmt.Sprintf("FAIL\t%s\treason=Unsupported mechanism in CONT\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + } +} + +// handleLoginCont handles continuation requests for LOGIN mechanism +func (s *Server) handleLoginCont(conn net.Conn, id, resp string, state *authState, authStates map[string]*authState) { + if resp == "" { + delete(authStates, id) + response := fmt.Sprintf("FAIL\t%s\treason=Invalid credentials format\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + return + } + + decoded, err := base64.StdEncoding.DecodeString(resp) + if err != nil { + delete(authStates, id) + log.Printf("Failed to decode base64 response: %v", err) + response := fmt.Sprintf("FAIL\t%s\treason=Invalid encoding\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + return + } + + switch state.Step { + case 1: + // Received username, ask for password + state.Username = string(decoded) + state.Step = 2 + + response := fmt.Sprintf("CONT\t%s\tPassword:\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + case 2: + // Received password, authenticate + delete(authStates, id) + password := string(decoded) + + log.Printf("LOGIN authentication attempt for user: %s", state.Username) + + if s.authenticate(state.Username, password) { + response := fmt.Sprintf("OK\t%s\tuser=%s\n", id, state.Username) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + log.Printf("Authentication successful for user: %s", state.Username) + } else { + response := fmt.Sprintf("FAIL\t%s\tuser=%s\treason=Invalid credentials\n", id, state.Username) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + log.Printf("Authentication failed for user: %s", state.Username) + } + default: + delete(authStates, id) + response := fmt.Sprintf("FAIL\t%s\treason=Invalid state\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + } +} diff --git a/internal/sasl/server_test.go b/internal/sasl/server_test.go index d481e7b..a9972aa 100644 --- a/internal/sasl/server_test.go +++ b/internal/sasl/server_test.go @@ -1352,3 +1352,125 @@ func TestSASLScopeConfiguration(t *testing.T) { }) } } + +// TestLoginMechanismWithInitialResponse tests LOGIN authentication mechanism when username is provided in the initial AUTH command +func TestLoginMechanismWithInitialResponse(t *testing.T) { + socketPath := getSocketPath(t) + + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer authServer.Close() + + server := sasl.NewServer(socketPath, "", authServer.URL, "example.com", conf.SASLScopeAll) + + // Start server + go func() { _ = server.Start() }() + defer func() { _ = server.Shutdown() }() + time.Sleep(100 * time.Millisecond) + + // Connect to the socket + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("Failed to connect to socket: %v", err) + } + defer func() { _ = conn.Close() }() + + // Encode username: testuser + encodedUser := base64.StdEncoding.EncodeToString([]byte("testuser")) + + // Send AUTH command with LOGIN mechanism and initial response (username) + _, _ = fmt.Fprintf(conn, "AUTH\t1\tLOGIN\tservice=smtp\tresp=%s\n", encodedUser) + + // Read response + reader := bufio.NewReader(conn) + response, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("Failed to read response: %v", err) + } + + // Should get a request for password + expectedResponse := "CONT\t1\tPassword:\n" + if response != expectedResponse { + t.Errorf("Expected %q, got: %q", expectedResponse, response) + } + + // Send password: testpass + encodedPass := base64.StdEncoding.EncodeToString([]byte("testpass")) + _, _ = fmt.Fprintf(conn, "CONT\t1\t%s\n", encodedPass) + + // Read response + response2, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("Failed to read second response: %v", err) + } + + // Should get OK response + if !strings.HasPrefix(response2, "OK\t1\t") { + t.Errorf("Expected OK response, got: %q", response2) + } +} + +// TestDoSProtection tests that concurrent auth attempts are correctly limited +func TestDoSProtection(t *testing.T) { + socketPath := getSocketPath(t) + + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer authServer.Close() + + server := sasl.NewServer(socketPath, "", authServer.URL, "example.com", conf.SASLScopeAll) + + // Start server + go func() { _ = server.Start() }() + defer func() { _ = server.Shutdown() }() + time.Sleep(100 * time.Millisecond) + + // Connect to the socket + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("Failed to connect to socket: %v", err) + } + defer func() { _ = conn.Close() }() + + reader := bufio.NewReader(conn) + + // Spawn maxAuthStatesPerConn (10) active auth states + // We will use LOGIN without initial response so they remain active + for i := 1; i <= 10; i++ { + _, _ = fmt.Fprintf(conn, "AUTH\t%d\tLOGIN\tservice=smtp\n", i) + response, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("Failed to read response for auth %d: %v", i, err) + } + expectedResponse := fmt.Sprintf("CONT\t%d\tUsername:\n", i) + if response != expectedResponse { + t.Fatalf("Expected CONT response for auth %d, got: %q", i, response) + } + } + + // Now try one more AUTH PLAIN (with or without initial response) + // It should immediately fail due to DoS protection + _, _ = fmt.Fprintf(conn, "AUTH\t11\tPLAIN\tservice=smtp\n") + response, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("Failed to read response for exceeded auth: %v", err) + } + + if !strings.HasPrefix(response, "FAIL\t11\t") || !strings.Contains(response, "reason=Too many authentication attempts") { + t.Errorf("Expected DoS FAIL response, got: %q", response) + } + + // Test that an AUTH attempt WITH an initial response is also correctly blocked by DoS protection + encodedUser := base64.StdEncoding.EncodeToString([]byte("testuser")) + _, _ = fmt.Fprintf(conn, "AUTH\t12\tLOGIN\tservice=smtp\tresp=%s\n", encodedUser) + response2, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("Failed to read response for exceeded auth with initial response: %v", err) + } + + if !strings.HasPrefix(response2, "FAIL\t12\t") || !strings.Contains(response2, "reason=Too many authentication attempts") { + t.Errorf("Expected DoS FAIL response with initial response, got: %q", response2) + } +} diff --git a/internal/server/auth/helpers_internal_test.go b/internal/server/auth/helpers_internal_test.go index c92612e..064a56f 100644 --- a/internal/server/auth/helpers_internal_test.go +++ b/internal/server/auth/helpers_internal_test.go @@ -385,10 +385,10 @@ type sslTrackingConn struct { } func (c *sslTrackingConn) Read(_ []byte) (int, error) { return 0, os.ErrClosed } -func (c *sslTrackingConn) Write(b []byte) (int, error) { return len(b), nil } -func (c *sslTrackingConn) Close() error { c.closed = true; return nil } -func (c *sslTrackingConn) LocalAddr() net.Addr { return nil } -func (c *sslTrackingConn) RemoteAddr() net.Addr { return nil } -func (c *sslTrackingConn) SetDeadline(_ time.Time) error { return nil } -func (c *sslTrackingConn) SetReadDeadline(_ time.Time) error { return nil } -func (c *sslTrackingConn) SetWriteDeadline(_ time.Time) error { return nil } +func (c *sslTrackingConn) Write(b []byte) (int, error) { return len(b), nil } +func (c *sslTrackingConn) Close() error { c.closed = true; return nil } +func (c *sslTrackingConn) LocalAddr() net.Addr { return nil } +func (c *sslTrackingConn) RemoteAddr() net.Addr { return nil } +func (c *sslTrackingConn) SetDeadline(_ time.Time) error { return nil } +func (c *sslTrackingConn) SetReadDeadline(_ time.Time) error { return nil } +func (c *sslTrackingConn) SetWriteDeadline(_ time.Time) error { return nil } diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 7e6bf2e..91a8707 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -5,7 +5,6 @@ import ( "testing" "raven/internal/models" - ) // HasCapabilityToken is a helper to check capability tokens exactly (avoids substring matches like LOGIN in LOGINDISABLED) @@ -152,7 +151,7 @@ func TestCapabilityCommand_ResponseFormat(t *testing.T) { srv.HandleCapability(conn, "C003", state) response := conn.GetWrittenData() - + // Check that response ends with CRLF if !strings.HasSuffix(response, "\r\n") { t.Errorf("Response should end with CRLF") @@ -222,7 +221,7 @@ func TestCapabilityCommand_MultipleInvocations(t *testing.T) { t.Errorf("Expected 'D001 OK CAPABILITY completed', got: %s", lines[1]) } - // Check second invocation + // Check second invocation if !strings.HasPrefix(lines[2], "* CAPABILITY ") { t.Errorf("Third response should be CAPABILITY, got: %s", lines[2]) } @@ -236,7 +235,7 @@ func TestCapabilityCommand_MultipleInvocations(t *testing.T) { } } -// TestCapabilityCommand_AuthenticationStateDoesNotAffectCapabilities tests that +// TestCapabilityCommand_AuthenticationStateDoesNotAffectCapabilities tests that // authentication state doesn't change capabilities (connection type does) func TestCapabilityCommand_AuthenticationStateDoesNotAffectCapabilities(t *testing.T) { srv := SetupTestServerSimple(t) @@ -268,7 +267,7 @@ func TestCapabilityCommand_AuthenticationStateDoesNotAffectCapabilities(t *testi // Capability list should be the same regardless of authentication state // (for the same connection type) if unauthCapLine != authCapLine { - t.Errorf("Capabilities should be same regardless of auth state:\nUnauth: %s\nAuth: %s", + t.Errorf("Capabilities should be same regardless of auth state:\nUnauth: %s\nAuth: %s", unauthCapLine, authCapLine) } } @@ -290,7 +289,7 @@ func BenchmarkCapabilityCommand(b *testing.B) { // TestCapabilityCommand_ConcurrentAccess tests concurrent CAPABILITY requests func TestCapabilityCommand_ConcurrentAccess(t *testing.T) { srv := SetupTestServerSimple(t) - + // Number of concurrent requests const numRequests = 10 responses := make([]string, numRequests) @@ -318,7 +317,7 @@ func TestCapabilityCommand_ConcurrentAccess(t *testing.T) { baseResponse := responses[0] for i := 1; i < numRequests; i++ { if responses[i] != baseResponse { - t.Errorf("Concurrent request %d produced different response:\nBase: %s\nGot: %s", + t.Errorf("Concurrent request %d produced different response:\nBase: %s\nGot: %s", i, baseResponse, responses[i]) } } diff --git a/internal/server/message/fetch.go b/internal/server/message/fetch.go index 9cb5454..5d11780 100644 --- a/internal/server/message/fetch.go +++ b/internal/server/message/fetch.go @@ -325,13 +325,13 @@ func processFetchForMessage(deps ServerDeps, conn net.Conn, messageID, uid int64 // Debug: Show parts structure fmt.Printf("DEBUG FETCH: Looking up part %v for message %d, have %d parts\n", partPath, messageID, len(parts)) for i, p := range parts { - fmt.Printf(" Part %d: id=%v, part_number=%v, parent_part_id=%v, content_type=%v\n", + fmt.Printf(" Part %d: id=%v, part_number=%v, parent_part_id=%v, content_type=%v\n", i, p["id"], p["part_number"], p["parent_part_id"], p["content_type"]) } - + // Map IMAP part number path to database part target := mapIMAPPartPathToDBPart(parts, partPath) - + fmt.Printf("DEBUG FETCH: mapIMAPPartPathToDBPart returned: %v\n", target != nil) payload := "" diff --git a/internal/server/response/bodystructure_test.go b/internal/server/response/bodystructure_test.go index 0c37146..3d474a3 100644 --- a/internal/server/response/bodystructure_test.go +++ b/internal/server/response/bodystructure_test.go @@ -6,195 +6,235 @@ import "testing" func raw(headers string, body string) string { return headers + "\r\n\r\n" + body } func TestBuildBodyStructure_TextPlain(t *testing.T) { - msg := raw("Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit", "Hello world\nLine2") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"TEXT", "PLAIN", "utf-8"}) { t.Errorf("missing basics: %s", bs) } + msg := raw("Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit", "Hello world\nLine2") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"TEXT", "PLAIN", "utf-8"}) { + t.Errorf("missing basics: %s", bs) + } } func TestBuildBodyStructure_Defaults(t *testing.T) { - msg := raw("Subject: X", "Body") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"TEXT", "PLAIN"}) { t.Errorf("expected default text/plain: %s", bs) } + msg := raw("Subject: X", "Body") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"TEXT", "PLAIN"}) { + t.Errorf("expected default text/plain: %s", bs) + } } func TestBuildBodyStructure_MultipartMixed(t *testing.T) { - boundary := "abc123" - headers := "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"" - part1 := "--" + boundary + "\r\nContent-Type: text/plain; charset=us-ascii\r\n\r\nPart1 text\r\n" - part2 := "--" + boundary + "\r\nContent-Type: text/html; charset=utf-8\r\n\r\nPart2\r\n" - end := "--" + boundary + "--\r\n" - msg := headers + "\r\n\r\n" + part1 + part2 + end - bs := BuildBodyStructure(msg) - // Expect two child parts and subtype MIXED with boundary parameter - if !containsAll(bs, []string{"PLAIN", "HTML", "MIXED", boundary}) { t.Errorf("missing multipart components: %s", bs) } + boundary := "abc123" + headers := "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"" + part1 := "--" + boundary + "\r\nContent-Type: text/plain; charset=us-ascii\r\n\r\nPart1 text\r\n" + part2 := "--" + boundary + "\r\nContent-Type: text/html; charset=utf-8\r\n\r\nPart2\r\n" + end := "--" + boundary + "--\r\n" + msg := headers + "\r\n\r\n" + part1 + part2 + end + bs := BuildBodyStructure(msg) + // Expect two child parts and subtype MIXED with boundary parameter + if !containsAll(bs, []string{"PLAIN", "HTML", "MIXED", boundary}) { + t.Errorf("missing multipart components: %s", bs) + } } func TestBuildBodyStructure_FallbackMultipart_NoBoundary(t *testing.T) { - // Missing boundary parameter should treat as generic multipart with no parts parsed -> fallback structure - headers := "Content-Type: multipart/mixed" - msg := headers + "\r\n\r\nIgnored" - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"MULTIPART", "MIXED"}) { t.Errorf("expected fallback multipart structure: %s", bs) } + // Missing boundary parameter should treat as generic multipart with no parts parsed -> fallback structure + headers := "Content-Type: multipart/mixed" + msg := headers + "\r\n\r\nIgnored" + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"MULTIPART", "MIXED"}) { + t.Errorf("expected fallback multipart structure: %s", bs) + } } func TestBuildBodyStructure_MultipartAlternative(t *testing.T) { - boundary := "bALT" - headers := "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"" - part1 := "--" + boundary + "\r\nContent-Type: text/plain; charset=us-ascii\r\n\r\nPlain text\r\n" - part2 := "--" + boundary + "\r\nContent-Type: text/html; charset=utf-8\r\n\r\nHTML\r\n" - end := "--" + boundary + "--\r\n" - msg := headers + "\r\n\r\n" + part1 + part2 + end - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"ALTERNATIVE", boundary}) { t.Errorf("expected alternative subtype with boundary: %s", bs) } + boundary := "bALT" + headers := "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"" + part1 := "--" + boundary + "\r\nContent-Type: text/plain; charset=us-ascii\r\n\r\nPlain text\r\n" + part2 := "--" + boundary + "\r\nContent-Type: text/html; charset=utf-8\r\n\r\nHTML\r\n" + end := "--" + boundary + "--\r\n" + msg := headers + "\r\n\r\n" + part1 + part2 + end + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"ALTERNATIVE", boundary}) { + t.Errorf("expected alternative subtype with boundary: %s", bs) + } } func TestBuildBodyStructure_WithContentID(t *testing.T) { - msg := raw("Content-Type: text/plain\r\nContent-ID: ", "Body") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"part123@example.com"}) { t.Error("expected Content-ID in structure") } + msg := raw("Content-Type: text/plain\r\nContent-ID: ", "Body") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"part123@example.com"}) { + t.Error("expected Content-ID in structure") + } } func TestBuildBodyStructure_WithContentDescription(t *testing.T) { - msg := raw("Content-Type: text/plain\r\nContent-Description: A text document", "Body") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"A text document"}) { t.Error("expected Content-Description in structure") } + msg := raw("Content-Type: text/plain\r\nContent-Description: A text document", "Body") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"A text document"}) { + t.Error("expected Content-Description in structure") + } } func TestBuildBodyStructure_Base64Encoding(t *testing.T) { - msg := raw("Content-Type: text/plain\r\nContent-Transfer-Encoding: base64", "SGVsbG8gV29ybGQ=") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"BASE64"}) { t.Error("expected BASE64 encoding") } + msg := raw("Content-Type: text/plain\r\nContent-Transfer-Encoding: base64", "SGVsbG8gV29ybGQ=") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"BASE64"}) { + t.Error("expected BASE64 encoding") + } } func TestBuildBodyStructure_QuotedPrintable(t *testing.T) { - msg := raw("Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable", "Hello=20World") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"QUOTED-PRINTABLE"}) { t.Error("expected QUOTED-PRINTABLE encoding") } + msg := raw("Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable", "Hello=20World") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"QUOTED-PRINTABLE"}) { + t.Error("expected QUOTED-PRINTABLE encoding") + } } func TestBuildBodyStructure_ImageType(t *testing.T) { - msg := raw("Content-Type: image/png\r\nContent-Transfer-Encoding: base64", "iVBORw0KGgo=") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"IMAGE", "PNG"}) { t.Error("expected IMAGE/PNG type") } + msg := raw("Content-Type: image/png\r\nContent-Transfer-Encoding: base64", "iVBORw0KGgo=") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"IMAGE", "PNG"}) { + t.Error("expected IMAGE/PNG type") + } } func TestBuildBodyStructure_ApplicationType(t *testing.T) { - msg := raw("Content-Type: application/pdf\r\nContent-Transfer-Encoding: base64", "JVBERi0xLjQ=") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"APPLICATION", "PDF"}) { t.Error("expected APPLICATION/PDF type") } + msg := raw("Content-Type: application/pdf\r\nContent-Transfer-Encoding: base64", "JVBERi0xLjQ=") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"APPLICATION", "PDF"}) { + t.Error("expected APPLICATION/PDF type") + } } func TestBuildBodyStructure_WithCharset(t *testing.T) { - msg := raw("Content-Type: text/html; charset=iso-8859-1", "Test") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"HTML", "iso-8859-1"}) { t.Error("expected HTML with charset") } + msg := raw("Content-Type: text/html; charset=iso-8859-1", "Test") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"HTML", "iso-8859-1"}) { + t.Error("expected HTML with charset") + } } func TestBuildBodyStructure_MultipartWithAttachment(t *testing.T) { - boundary := "att123" - headers := "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"" - part1 := "--" + boundary + "\r\nContent-Type: text/plain\r\n\r\nMessage body\r\n" - part2 := "--" + boundary + "\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"doc.pdf\"\r\n\r\nPDF data\r\n" - end := "--" + boundary + "--\r\n" - msg := headers + "\r\n\r\n" + part1 + part2 + end - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"MIXED", "APPLICATION", "PDF"}) { t.Error("expected multipart with attachment") } + boundary := "att123" + headers := "Content-Type: multipart/mixed; boundary=\"" + boundary + "\"" + part1 := "--" + boundary + "\r\nContent-Type: text/plain\r\n\r\nMessage body\r\n" + part2 := "--" + boundary + "\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=\"doc.pdf\"\r\n\r\nPDF data\r\n" + end := "--" + boundary + "--\r\n" + msg := headers + "\r\n\r\n" + part1 + part2 + end + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"MIXED", "APPLICATION", "PDF"}) { + t.Error("expected multipart with attachment") + } } func TestBuildBodyStructure_EmptyBody(t *testing.T) { - msg := raw("Content-Type: text/plain", "") - bs := BuildBodyStructure(msg) - if !containsAll(bs, []string{"TEXT", "PLAIN"}) { t.Error("expected valid structure for empty body") } + msg := raw("Content-Type: text/plain", "") + bs := BuildBodyStructure(msg) + if !containsAll(bs, []string{"TEXT", "PLAIN"}) { + t.Error("expected valid structure for empty body") + } } func TestBuildBodyStructure_NoContentType(t *testing.T) { - msg := raw("Subject: Test", "Body content") - bs := BuildBodyStructure(msg) - // Should default to text/plain - if !containsAll(bs, []string{"TEXT", "PLAIN"}) { t.Error("expected default text/plain") } + msg := raw("Subject: Test", "Body content") + bs := BuildBodyStructure(msg) + // Should default to text/plain + if !containsAll(bs, []string{"TEXT", "PLAIN"}) { + t.Error("expected default text/plain") + } } func TestBuildBodyStructure_InvalidContentType(t *testing.T) { - msg := raw("Content-Type: invalid", "Body") - bs := BuildBodyStructure(msg) - // Should fallback to text/plain - if !containsAll(bs, []string{"TEXT"}) { t.Error("expected fallback to text") } + msg := raw("Content-Type: invalid", "Body") + bs := BuildBodyStructure(msg) + // Should fallback to text/plain + if !containsAll(bs, []string{"TEXT"}) { + t.Error("expected fallback to text") + } } func TestBuildBodyStructure_MultipartNested(t *testing.T) { - outerBoundary := "outer" - innerBoundary := "inner" - headers := "Content-Type: multipart/mixed; boundary=\"" + outerBoundary + "\"" - part1 := "--" + outerBoundary + "\r\nContent-Type: multipart/alternative; boundary=\"" + innerBoundary + "\"\r\n\r\n" - part1a := "--" + innerBoundary + "\r\nContent-Type: text/plain\r\n\r\nPlain\r\n" - part1b := "--" + innerBoundary + "\r\nContent-Type: text/html\r\n\r\nHTML\r\n" - part1end := "--" + innerBoundary + "--\r\n" - end := "--" + outerBoundary + "--\r\n" - msg := headers + "\r\n\r\n" + part1 + part1a + part1b + part1end + end - bs := BuildBodyStructure(msg) - // Nested multipart is complex - just check it has MIXED subtype - if !containsAll(bs, []string{"MIXED"}) { t.Errorf("expected MIXED multipart structure, got: %s", bs) } + outerBoundary := "outer" + innerBoundary := "inner" + headers := "Content-Type: multipart/mixed; boundary=\"" + outerBoundary + "\"" + part1 := "--" + outerBoundary + "\r\nContent-Type: multipart/alternative; boundary=\"" + innerBoundary + "\"\r\n\r\n" + part1a := "--" + innerBoundary + "\r\nContent-Type: text/plain\r\n\r\nPlain\r\n" + part1b := "--" + innerBoundary + "\r\nContent-Type: text/html\r\n\r\nHTML\r\n" + part1end := "--" + innerBoundary + "--\r\n" + end := "--" + outerBoundary + "--\r\n" + msg := headers + "\r\n\r\n" + part1 + part1a + part1b + part1end + end + bs := BuildBodyStructure(msg) + // Nested multipart is complex - just check it has MIXED subtype + if !containsAll(bs, []string{"MIXED"}) { + t.Errorf("expected MIXED multipart structure, got: %s", bs) + } } // TestBuildBodyStructure_GmailScenario tests the exact Gmail mobile client scenario // multipart/mixed containing multipart/alternative (text parts) and an attachment func TestBuildBodyStructure_GmailScenario(t *testing.T) { - outerBoundary := "----=_Part_Mixed_123" - innerBoundary := "----=_Part_Alternative_456" + outerBoundary := "----=_Part_Mixed_123" + innerBoundary := "----=_Part_Alternative_456" - headers := "Content-Type: multipart/mixed; boundary=\"" + outerBoundary + "\"\r\n" + - "MIME-Version: 1.0" + headers := "Content-Type: multipart/mixed; boundary=\"" + outerBoundary + "\"\r\n" + + "MIME-Version: 1.0" - // Part 1: multipart/alternative with text/plain and text/html - part1 := "--" + outerBoundary + "\r\nContent-Type: multipart/alternative; boundary=\"" + innerBoundary + "\"\r\n\r\n" - part1a := "--" + innerBoundary + "\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHi Test gmail client,\r\n" - part1b := "--" + innerBoundary + "\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHi Test gmail client,\r\n" - part1end := "--" + innerBoundary + "--\r\n" + // Part 1: multipart/alternative with text/plain and text/html + part1 := "--" + outerBoundary + "\r\nContent-Type: multipart/alternative; boundary=\"" + innerBoundary + "\"\r\n\r\n" + part1a := "--" + innerBoundary + "\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHi Test gmail client,\r\n" + part1b := "--" + innerBoundary + "\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHi Test gmail client,\r\n" + part1end := "--" + innerBoundary + "--\r\n" - // Part 2: image attachment - part2 := "--" + outerBoundary + "\r\n" + - "Content-Type: image/png; name=\"test.png\"\r\n" + - "Content-Transfer-Encoding: base64\r\n" + - "Content-Disposition: attachment; filename=\"test.png\"\r\n" + - "Content-ID: \r\n\r\n" + - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\r\n" + // Part 2: image attachment + part2 := "--" + outerBoundary + "\r\n" + + "Content-Type: image/png; name=\"test.png\"\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "Content-Disposition: attachment; filename=\"test.png\"\r\n" + + "Content-ID: \r\n\r\n" + + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\r\n" - end := "--" + outerBoundary + "--\r\n" + end := "--" + outerBoundary + "--\r\n" - msg := headers + "\r\n\r\n" + part1 + part1a + part1b + part1end + part2 + end - bs := BuildBodyStructure(msg) + msg := headers + "\r\n\r\n" + part1 + part1a + part1b + part1end + part2 + end + bs := BuildBodyStructure(msg) - t.Logf("Generated BODYSTRUCTURE: %s", bs) + t.Logf("Generated BODYSTRUCTURE: %s", bs) - // Critical checks for Gmail compatibility: - // 1. Should have MIXED as outer type - // 2. Should have ALTERNATIVE for nested multipart (not "MULTIPART" "ALTERNATIVE" as a single part) - // 3. Should have TEXT PLAIN and TEXT HTML as children of alternative - // 4. Should have IMAGE PNG as attachment - if !containsAll(bs, []string{"MIXED", "ALTERNATIVE", "TEXT", "PLAIN", "HTML", "IMAGE", "PNG"}) { - t.Errorf("expected proper nested structure with all parts, got: %s", bs) - } + // Critical checks for Gmail compatibility: + // 1. Should have MIXED as outer type + // 2. Should have ALTERNATIVE for nested multipart (not "MULTIPART" "ALTERNATIVE" as a single part) + // 3. Should have TEXT PLAIN and TEXT HTML as children of alternative + // 4. Should have IMAGE PNG as attachment + if !containsAll(bs, []string{"MIXED", "ALTERNATIVE", "TEXT", "PLAIN", "HTML", "IMAGE", "PNG"}) { + t.Errorf("expected proper nested structure with all parts, got: %s", bs) + } - // Make sure it doesn't have the broken format ("MULTIPART" "ALTERNATIVE") - if containsAll(bs, []string{"\"MULTIPART\"", "\"ALTERNATIVE\""}) { - t.Errorf("BODYSTRUCTURE incorrectly shows MULTIPART as an atomic part type: %s", bs) - } + // Make sure it doesn't have the broken format ("MULTIPART" "ALTERNATIVE") + if containsAll(bs, []string{"\"MULTIPART\"", "\"ALTERNATIVE\""}) { + t.Errorf("BODYSTRUCTURE incorrectly shows MULTIPART as an atomic part type: %s", bs) + } } func TestBuildParamList_Empty(t *testing.T) { - params := make(map[string]string) - result := buildParamList(params) - if result != "NIL" { t.Errorf("expected NIL for empty params, got %s", result) } + params := make(map[string]string) + result := buildParamList(params) + if result != "NIL" { + t.Errorf("expected NIL for empty params, got %s", result) + } } func TestBuildParamList_Single(t *testing.T) { - params := map[string]string{"charset": "utf-8"} - result := buildParamList(params) - if !containsAll(result, []string{"CHARSET", "utf-8"}) { t.Error("expected charset param") } + params := map[string]string{"charset": "utf-8"} + result := buildParamList(params) + if !containsAll(result, []string{"CHARSET", "utf-8"}) { + t.Error("expected charset param") + } } func TestBuildParamList_Multiple(t *testing.T) { - params := map[string]string{"charset": "utf-8", "name": "file.txt"} - result := buildParamList(params) - if !containsAll(result, []string{"CHARSET", "NAME", "utf-8", "file.txt"}) { t.Error("expected multiple params") } + params := map[string]string{"charset": "utf-8", "name": "file.txt"} + result := buildParamList(params) + if !containsAll(result, []string{"CHARSET", "NAME", "utf-8", "file.txt"}) { + t.Error("expected multiple params") + } } diff --git a/internal/server/testing_support.go b/internal/server/testing_support.go index c6606fc..61a847a 100644 --- a/internal/server/testing_support.go +++ b/internal/server/testing_support.go @@ -28,8 +28,8 @@ import ( ) var ( - testUserMu sync.Mutex - testUserNextID int64 = 1 + testUserMu sync.Mutex + testUserNextID int64 = 1 testUserIDByEmail = map[string]int64{} testUserEmailByID = map[int64]string{} ) diff --git a/internal/socketmap/thunder/client.go b/internal/socketmap/thunder/client.go index 2c72ab9..8f7f8e4 100644 --- a/internal/socketmap/thunder/client.go +++ b/internal/socketmap/thunder/client.go @@ -17,4 +17,4 @@ func GetHTTPClient() *http.Client { }, }, } -} \ No newline at end of file +} diff --git a/internal/socketmap/thunder/group.go b/internal/socketmap/thunder/group.go index bd50651..b043119 100644 --- a/internal/socketmap/thunder/group.go +++ b/internal/socketmap/thunder/group.go @@ -103,4 +103,4 @@ func ValidateGroupAddress(email, host, port string, tokenRefreshSeconds int) (bo log.Printf(" │ ✗ Group found but OU/name mismatch") return false, nil -} \ No newline at end of file +} diff --git a/test/integration/sasl/sasl_integration_test.go b/test/integration/sasl/sasl_integration_test.go index b5c68a5..a9596ce 100644 --- a/test/integration/sasl/sasl_integration_test.go +++ b/test/integration/sasl/sasl_integration_test.go @@ -190,7 +190,21 @@ func TestSASLPlainWithoutInitialResponse(t *testing.T) { if !strings.HasPrefix(contResponse, "CONT\t3") { t.Errorf("Expected CONT response, got: %s", contResponse) } - t.Log("✓ PLAIN authentication continuation handled correctly") + t.Log("✓ PLAIN authentication continuation prompt handled correctly") + + // Send base64 credentials in CONT + username := "alice" + password := "validpass123" + credentials := fmt.Sprintf("\x00%s\x00%s", username, password) + encoded := base64.StdEncoding.EncodeToString([]byte(credentials)) + + client.SendCommand("CONT\t3\t" + encoded) + authResponse := client.ReadResponse() + + if !strings.HasPrefix(authResponse, "OK\t3") { + t.Errorf("Expected OK response, got: %s", authResponse) + } + t.Log("✓ PLAIN authentication via CONT handled correctly") } // TestSASLInvalidMechanism tests authentication with unsupported mechanism @@ -619,18 +633,32 @@ func TestSASLLoginMechanism(t *testing.T) { } t.Log("✓ LOGIN mechanism requests username as expected") - // Try LOGIN mechanism with response (should fail as not fully implemented) - client.SendCommand("AUTH\t2\tLOGIN\tservice=smtp\tresp=dGVzdA==") // base64 "test" + // Try LOGIN mechanism with continuation + username := "alice" + encodedUsername := base64.StdEncoding.EncodeToString([]byte(username)) + client.SendCommand("CONT\t1\t" + encodedUsername) - failResponse := client.ReadResponse() - t.Logf("LOGIN mechanism with response: %s", failResponse) + contPasswordResponse := client.ReadResponse() + t.Logf("LOGIN mechanism password continuation response: %s", contPasswordResponse) - // Should get FAIL for incomplete implementation - if !strings.HasPrefix(failResponse, "FAIL\t2") { - t.Errorf("Expected FAIL response for LOGIN with response, got: %s", failResponse) + // Should get CONT asking for password + if !strings.HasPrefix(contPasswordResponse, "CONT\t1") { + t.Errorf("Expected CONT response for LOGIN password, got: %s", contPasswordResponse) + } + if !strings.Contains(contPasswordResponse, "Password:") { + t.Errorf("Expected 'Password:' prompt, got: %s", contPasswordResponse) } - if !strings.Contains(failResponse, "LOGIN not fully implemented") { - t.Errorf("Expected 'LOGIN not fully implemented' message, got: %s", failResponse) + t.Log("✓ LOGIN mechanism requests password as expected") + + password := "validpass123" + encodedPassword := base64.StdEncoding.EncodeToString([]byte(password)) + client.SendCommand("CONT\t1\t" + encodedPassword) + + authResponse := client.ReadResponse() + t.Logf("LOGIN mechanism auth response: %s", authResponse) + + if !strings.HasPrefix(authResponse, "OK\t1") { + t.Errorf("Expected OK response, got: %s", authResponse) } - t.Log("✓ LOGIN mechanism correctly indicates incomplete implementation") + t.Log("✓ LOGIN mechanism authenticates successfully") }