From 0e6fc27bc1624f4b87281f60ebd4fc25e8b24478 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 25 Jul 2026 04:20:21 -0300 Subject: [PATCH] #8: implement ManageSieve protocol server (RFC 5804) --- .../cmd/lambdamail-protocols/main.go | 12 + .../application/port/sieve_repository.go | 22 + .../usecase/managesieve_session.go | 127 ++++++ .../usecase/managesieve_session_test.go | 150 ++++++ .../internal/domain/entity/sieve_script.go | 12 + .../internal/domain/entity/sieve_validator.go | 117 +++++ .../domain/entity/sieve_validator_test.go | 59 +++ .../internal/e2e/managesieve_e2e_test.go | 216 +++++++++ .../postgres/sieve_repository.go | 120 +++++ .../postgres/sieve_repository_test.go | 75 +++ .../internal/presentation/managesieve/conn.go | 428 ++++++++++++++++++ .../presentation/managesieve/conn_test.go | 243 ++++++++++ .../presentation/managesieve/server.go | 53 +++ 13 files changed, 1634 insertions(+) create mode 100644 services/protocols/internal/application/port/sieve_repository.go create mode 100644 services/protocols/internal/application/usecase/managesieve_session.go create mode 100644 services/protocols/internal/application/usecase/managesieve_session_test.go create mode 100644 services/protocols/internal/domain/entity/sieve_script.go create mode 100644 services/protocols/internal/domain/entity/sieve_validator.go create mode 100644 services/protocols/internal/domain/entity/sieve_validator_test.go create mode 100644 services/protocols/internal/e2e/managesieve_e2e_test.go create mode 100644 services/protocols/internal/infrastructure/postgres/sieve_repository.go create mode 100644 services/protocols/internal/infrastructure/postgres/sieve_repository_test.go create mode 100644 services/protocols/internal/presentation/managesieve/conn.go create mode 100644 services/protocols/internal/presentation/managesieve/conn_test.go create mode 100644 services/protocols/internal/presentation/managesieve/server.go diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index d275304..adabdb1 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -18,6 +18,7 @@ import ( "lambdamail/protocols/internal/infrastructure/postgres" tlsprovider "lambdamail/protocols/internal/infrastructure/tls" imappresentation "lambdamail/protocols/internal/presentation/imap" + managesievepresentation "lambdamail/protocols/internal/presentation/managesieve" pop3presentation "lambdamail/protocols/internal/presentation/pop3" smtppresentation "lambdamail/protocols/internal/presentation/smtp" ) @@ -122,6 +123,17 @@ func main() { } }() + sieveRepo := postgres.NewSieveRepository(pool) + sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo) + sieveServer := managesievepresentation.NewServer(":4190", sieveUseCase, certProvider) + + go func() { + log.Printf("lambdamail-protocols ManageSieve listening on :4190") + if err := sieveServer.ListenAndServe(); err != nil { + log.Fatalf("managesieve serve: %v", err) + } + }() + handler := health.Handler(func() error { return pool.Ping(ctx) }) addr := ":8080" log.Printf("lambdamail-protocols health listening on %s", addr) diff --git a/services/protocols/internal/application/port/sieve_repository.go b/services/protocols/internal/application/port/sieve_repository.go new file mode 100644 index 0000000..1daf7a1 --- /dev/null +++ b/services/protocols/internal/application/port/sieve_repository.go @@ -0,0 +1,22 @@ +package port + +import "context" + +// SieveScriptRecord represents a persistent Sieve script row in PostgreSQL. +type SieveScriptRecord struct { + ID string + MailboxID string + Name string + Script string + IsActive bool +} + +// SieveRepository defines data operations for Sieve script management. +type SieveRepository interface { + GetScript(ctx context.Context, mailboxID string, name string) (*SieveScriptRecord, error) + PutScript(ctx context.Context, mailboxID string, name string, script string) error + ListScripts(ctx context.Context, mailboxID string) ([]SieveScriptRecord, error) + SetActiveScript(ctx context.Context, mailboxID string, name string) error + DeleteScript(ctx context.Context, mailboxID string, name string) error + RenameScript(ctx context.Context, mailboxID string, oldName string, newName string) error +} diff --git a/services/protocols/internal/application/usecase/managesieve_session.go b/services/protocols/internal/application/usecase/managesieve_session.go new file mode 100644 index 0000000..af080dd --- /dev/null +++ b/services/protocols/internal/application/usecase/managesieve_session.go @@ -0,0 +1,127 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + + "github.com/alexedwards/argon2id" + + "lambdamail/protocols/internal/application/port" + "lambdamail/protocols/internal/domain/entity" +) + +var ( + ErrScriptNotFound = errors.New("script does not exist") + ErrActiveScriptDelete = errors.New("cannot delete active script") + ErrScriptAlreadyExists = errors.New("script with new name already exists") + ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit") +) + +const MaxScriptSizeBytes = 1024 * 1024 // 1 MB limit per Sieve script + +type ManageSieveSessionUseCase struct { + authRepo port.AuthRepository + sieveRepo port.SieveRepository +} + +func NewManageSieveSessionUseCase(authRepo port.AuthRepository, sieveRepo port.SieveRepository) *ManageSieveSessionUseCase { + return &ManageSieveSessionUseCase{ + authRepo: authRepo, + sieveRepo: sieveRepo, + } +} + +func (uc *ManageSieveSessionUseCase) Login(ctx context.Context, address, password string) (string, error) { + rec, err := uc.authRepo.FindByAddress(ctx, address) + if err != nil { + return "", fmt.Errorf("find mailbox: %w", err) + } + if rec == nil { + return "", ErrAuthFailed + } + match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) + if err != nil || !match { + return "", ErrAuthFailed + } + return rec.ID.String(), nil +} + +func (uc *ManageSieveSessionUseCase) PutScript(ctx context.Context, mailboxID, name, content string) error { + if len(content) > MaxScriptSizeBytes { + return ErrScriptQuotaExceeded + } + if err := entity.ValidateSieveScript(content); err != nil { + return fmt.Errorf("invalid sieve script syntax: %w", err) + } + return uc.sieveRepo.PutScript(ctx, mailboxID, name, content) +} + +func (uc *ManageSieveSessionUseCase) GetScript(ctx context.Context, mailboxID, name string) (*port.SieveScriptRecord, error) { + rec, err := uc.sieveRepo.GetScript(ctx, mailboxID, name) + if err != nil { + return nil, err + } + if rec == nil { + return nil, ErrScriptNotFound + } + return rec, nil +} + +func (uc *ManageSieveSessionUseCase) ListScripts(ctx context.Context, mailboxID string) ([]port.SieveScriptRecord, error) { + return uc.sieveRepo.ListScripts(ctx, mailboxID) +} + +func (uc *ManageSieveSessionUseCase) SetActive(ctx context.Context, mailboxID, name string) error { + if name != "" { + rec, err := uc.sieveRepo.GetScript(ctx, mailboxID, name) + if err != nil { + return err + } + if rec == nil { + return ErrScriptNotFound + } + } + return uc.sieveRepo.SetActiveScript(ctx, mailboxID, name) +} + +func (uc *ManageSieveSessionUseCase) DeleteScript(ctx context.Context, mailboxID, name string) error { + rec, err := uc.sieveRepo.GetScript(ctx, mailboxID, name) + if err != nil { + return err + } + if rec == nil { + return ErrScriptNotFound + } + if rec.IsActive { + return ErrActiveScriptDelete + } + return uc.sieveRepo.DeleteScript(ctx, mailboxID, name) +} + +func (uc *ManageSieveSessionUseCase) RenameScript(ctx context.Context, mailboxID, oldName, newName string) error { + oldRec, err := uc.sieveRepo.GetScript(ctx, mailboxID, oldName) + if err != nil { + return err + } + if oldRec == nil { + return ErrScriptNotFound + } + + newRec, err := uc.sieveRepo.GetScript(ctx, mailboxID, newName) + if err != nil { + return err + } + if newRec != nil { + return ErrScriptAlreadyExists + } + + return uc.sieveRepo.RenameScript(ctx, mailboxID, oldName, newName) +} + +func (uc *ManageSieveSessionUseCase) CheckScript(_ context.Context, content string) error { + if len(content) > MaxScriptSizeBytes { + return ErrScriptQuotaExceeded + } + return entity.ValidateSieveScript(content) +} diff --git a/services/protocols/internal/application/usecase/managesieve_session_test.go b/services/protocols/internal/application/usecase/managesieve_session_test.go new file mode 100644 index 0000000..4112565 --- /dev/null +++ b/services/protocols/internal/application/usecase/managesieve_session_test.go @@ -0,0 +1,150 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "lambdamail/protocols/internal/application/port" +) + +type fakeSieveRepository struct { + scripts map[string]map[string]port.SieveScriptRecord // mailboxID -> name -> record +} + +func newFakeSieveRepository() *fakeSieveRepository { + return &fakeSieveRepository{ + scripts: make(map[string]map[string]port.SieveScriptRecord), + } +} + +func (f *fakeSieveRepository) GetScript(_ context.Context, mailboxID, name string) (*port.SieveScriptRecord, error) { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil, nil + } + rec, ok := mb[name] + if !ok { + return nil, nil + } + return &rec, nil +} + +func (f *fakeSieveRepository) PutScript(_ context.Context, mailboxID, name, script string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + mb = make(map[string]port.SieveScriptRecord) + f.scripts[mailboxID] = mb + } + existing, exists := mb[name] + isActive := false + if exists { + isActive = existing.IsActive + } + mb[name] = port.SieveScriptRecord{ + ID: uuid.New().String(), + MailboxID: mailboxID, + Name: name, + Script: script, + IsActive: isActive, + } + return nil +} + +func (f *fakeSieveRepository) ListScripts(_ context.Context, mailboxID string) ([]port.SieveScriptRecord, error) { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil, nil + } + var out []port.SieveScriptRecord + for _, rec := range mb { + out = append(out, rec) + } + return out, nil +} + +func (f *fakeSieveRepository) SetActiveScript(_ context.Context, mailboxID, name string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil + } + for k, rec := range mb { + rec.IsActive = (k == name) + mb[k] = rec + } + return nil +} + +func (f *fakeSieveRepository) DeleteScript(_ context.Context, mailboxID, name string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil + } + delete(mb, name) + return nil +} + +func (f *fakeSieveRepository) RenameScript(_ context.Context, mailboxID, oldName, newName string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil + } + rec := mb[oldName] + delete(mb, oldName) + rec.Name = newName + mb[newName] = rec + return nil +} + +func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) { + mailboxID := uuid.New() + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash}, + }} + sieveRepo := newFakeSieveRepository() + uc := NewManageSieveSessionUseCase(auth, sieveRepo) + + ctx := context.Background() + + // 1. Login + gotID, err := uc.Login(ctx, "user@example.test", testPassword) + if err != nil || gotID != mailboxID.String() { + t.Fatalf("Login failed: err=%v, gotID=%s", err, gotID) + } + + // 2. PutScript valid + scriptContent := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }` + if err := uc.PutScript(ctx, gotID, "main_rules", scriptContent); err != nil { + t.Fatalf("PutScript failed: %v", err) + } + + // 3. PutScript invalid syntax + if err := uc.PutScript(ctx, gotID, "bad_rules", `if header "Subject" {`); err == nil { + t.Fatal("expected error for invalid Sieve syntax, got nil") + } + + // 4. GetScript + rec, err := uc.GetScript(ctx, gotID, "main_rules") + if err != nil || rec == nil || rec.Script != scriptContent { + t.Fatalf("GetScript failed: rec=%+v, err=%v", rec, err) + } + + // 5. SetActive + if err := uc.SetActive(ctx, gotID, "main_rules"); err != nil { + t.Fatalf("SetActive failed: %v", err) + } + + // 6. Delete active script fails per RFC 5804 ยง2.7 + if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) { + t.Fatalf("expected ErrActiveScriptDelete, got %v", err) + } + + // 7. Deactivate all and delete + if err := uc.SetActive(ctx, gotID, ""); err != nil { + t.Fatalf("SetActive empty failed: %v", err) + } + if err := uc.DeleteScript(ctx, gotID, "main_rules"); err != nil { + t.Fatalf("DeleteScript failed: %v", err) + } +} diff --git a/services/protocols/internal/domain/entity/sieve_script.go b/services/protocols/internal/domain/entity/sieve_script.go new file mode 100644 index 0000000..ee50696 --- /dev/null +++ b/services/protocols/internal/domain/entity/sieve_script.go @@ -0,0 +1,12 @@ +package entity + +import "github.com/google/uuid" + +// SieveScript represents a user's Sieve filtering script entity. +type SieveScript struct { + ID uuid.UUID + MailboxID uuid.UUID + Name string + Script string + IsActive bool +} diff --git a/services/protocols/internal/domain/entity/sieve_validator.go b/services/protocols/internal/domain/entity/sieve_validator.go new file mode 100644 index 0000000..b5f5f9e --- /dev/null +++ b/services/protocols/internal/domain/entity/sieve_validator.go @@ -0,0 +1,117 @@ +package entity + +import ( + "errors" + "fmt" + "strings" +) + +var ( + ErrEmptyScript = errors.New("sieve script cannot be empty") + ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script") + ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script") + ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script") + ErrUnterminatedComment = errors.New("unterminated comment block in sieve script") +) + +// ValidateSieveScript performs structural syntax validation of an RFC 5228 Sieve script. +func ValidateSieveScript(script string) error { + trimmed := strings.TrimSpace(script) + if trimmed == "" { + return ErrEmptyScript + } + + var braceCount int + var bracketCount int + inSingleQuote := false + inMultiComment := false + inString := false + + runes := []rune(script) + n := len(runes) + + for i := 0; i < n; i++ { + r := runes[i] + + // Handle multi-line comment /* ... */ + if inMultiComment { + if r == '*' && i+1 < n && runes[i+1] == '/' { + inMultiComment = false + i++ + } + continue + } + + // Handle string literal "..." + if inString { + if r == '\\' { + i++ // skip escaped char + continue + } + if r == '"' { + inString = false + } + continue + } + + // Handle single line comment # ... + if inSingleQuote { + if r == '\n' { + inSingleQuote = false + } + continue + } + + // Detect start of comment /* + if r == '/' && i+1 < n && runes[i+1] == '*' { + inMultiComment = true + i++ + continue + } + + // Detect start of comment # + if r == '#' { + inSingleQuote = true + continue + } + + // Detect start of string " + if r == '"' { + inString = true + continue + } + + // Track braces & brackets + switch r { + case '{': + braceCount++ + case '}': + braceCount-- + if braceCount < 0 { + return ErrUnmatchedBrace + } + case '[': + bracketCount++ + case ']': + bracketCount-- + if bracketCount < 0 { + return ErrUnmatchedBracket + } + } + } + + if inMultiComment { + return ErrUnterminatedComment + } + if inString { + return ErrUnterminatedQuote + } + if braceCount != 0 { + return fmt.Errorf("%w: balance is %d", ErrUnmatchedBrace, braceCount) + } + if bracketCount != 0 { + return fmt.Errorf("%w: balance is %d", ErrUnmatchedBracket, bracketCount) + } + + return nil +} diff --git a/services/protocols/internal/domain/entity/sieve_validator_test.go b/services/protocols/internal/domain/entity/sieve_validator_test.go new file mode 100644 index 0000000..e27e87c --- /dev/null +++ b/services/protocols/internal/domain/entity/sieve_validator_test.go @@ -0,0 +1,59 @@ +package entity + +import ( + "errors" + "testing" +) + +func TestValidateSieveScript_ValidScripts(t *testing.T) { + validScripts := []string{ + `require ["fileinto", "reject"]; +if header :is "Subject" "Spam" { + fileinto "Junk"; + stop; +}`, + `# Simple keep script +keep;`, + `/* Multi-line comment + testing Sieve script */ +if size :over 1M { + discard; +} else { + keep; +}`, + } + + for i, s := range validScripts { + if err := ValidateSieveScript(s); err != nil { + t.Errorf("script %d failed: %v", i, err) + } + } +} + +func TestValidateSieveScript_InvalidScripts(t *testing.T) { + tests := []struct { + name string + script string + wantErr error + }{ + {"empty", "", ErrEmptyScript}, + {"whitespace", " \n\t ", ErrEmptyScript}, + {"unmatched opening brace", `if true { keep;`, ErrUnmatchedBrace}, + {"unmatched closing brace", `if true { keep; }}`, ErrUnmatchedBrace}, + {"unmatched bracket", `require ["fileinto";`, ErrUnmatchedBracket}, + {"unterminated string", `fileinto "Junk;`, ErrUnterminatedQuote}, + {"unterminated comment", `/* comment without end`, ErrUnterminatedComment}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSieveScript(tt.script) + if err == nil { + t.Fatalf("expected error for %s, got nil", tt.name) + } + if !errors.Is(err, tt.wantErr) { + t.Errorf("error = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/services/protocols/internal/e2e/managesieve_e2e_test.go b/services/protocols/internal/e2e/managesieve_e2e_test.go new file mode 100644 index 0000000..da61a89 --- /dev/null +++ b/services/protocols/internal/e2e/managesieve_e2e_test.go @@ -0,0 +1,216 @@ +package e2e + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alexedwards/argon2id" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + + appusecase "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/infrastructure/postgres" + managesievepresentation "lambdamail/protocols/internal/presentation/managesieve" +) + +func TestManageSieveEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54333). // distinct port for ManageSieve e2e test execution + RuntimePath(runtimeDir). + StartTimeout(120 * time.Second)) + if err := pg.Start(); err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + defer pg.Stop() + + dbURL := "postgres://postgres:postgres@localhost:54333/postgres?sslmode=disable" + pool, err := postgres.NewPool(ctx, dbURL) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + root := repoRoot(t) + sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql")) + if err != nil { + t.Fatalf("read migration: %v", err) + } + if _, err := pool.Exec(ctx, string(sql)); err != nil { + t.Fatalf("apply migration: %v", err) + } + + // --- Seed domain & mailbox ----------------------------------------------- + password := "correct horse battery staple" + hash, err := argon2id.CreateHash(password, argon2id.DefaultParams) + if err != nil { + t.Fatalf("hash password: %v", err) + } + domainID := "00000000-0000-0000-0000-000000000001" + mailboxID := "00000000-0000-0000-0000-000000000002" + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'example.test', 'example.test')`, domainID); err != nil { + t.Fatalf("seed domain: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', 'user@example.test', $3)`, mailboxID, domainID, hash); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + + // Setup ManageSieve Server + sieveUC := appusecase.NewManageSieveSessionUseCase( + postgres.NewAuthRepository(pool), + postgres.NewSieveRepository(pool), + ) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + srv := managesievepresentation.NewServer(ln.Addr().String(), sieveUC, nil) + go srv.Serve(ln) + defer srv.Close() + + // Dial ManageSieve server + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial ManageSieve: %v", err) + } + defer conn.Close() + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + + readLine := func() string { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read line: %v", err) + } + return strings.TrimRight(line, "\r\n") + } + + sendCmd := func(cmd string) { + writer.WriteString(cmd + "\r\n") + writer.Flush() + } + + // 1. Greeting + for { + l := readLine() + if strings.HasPrefix(l, "OK") { + break + } + } + + // 2. CAPABILITY + sendCmd("CAPABILITY") + for { + l := readLine() + if strings.HasPrefix(l, "OK") { + break + } + } + + // 3. AUTHENTICATE PLAIN + saslPayload := base64.StdEncoding.EncodeToString([]byte("\x00user@example.test\x00correct horse battery staple")) + sendCmd(fmt.Sprintf("AUTHENTICATE \"PLAIN\" %s", saslPayload)) + if authResp := readLine(); !strings.HasPrefix(authResp, "OK") { + t.Fatalf("AUTH resp = %q, want OK", authResp) + } + + // 4. CHECKSCRIPT + scriptContent := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }` + sendCmd(fmt.Sprintf("CHECKSCRIPT {%d+}\r\n%s", len(scriptContent), scriptContent)) + if checkResp := readLine(); !strings.HasPrefix(checkResp, "OK") { + t.Fatalf("CHECKSCRIPT resp = %q", checkResp) + } + + // 5. PUTSCRIPT + sendCmd(fmt.Sprintf("PUTSCRIPT \"my_rules\" {%d+}\r\n%s", len(scriptContent), scriptContent)) + if putResp := readLine(); !strings.HasPrefix(putResp, "OK") { + t.Fatalf("PUTSCRIPT resp = %q", putResp) + } + + // 6. GETSCRIPT + sendCmd("GETSCRIPT \"my_rules\"") + getLiteralHead := readLine() + if !strings.HasPrefix(getLiteralHead, "{") { + t.Fatalf("GETSCRIPT literal head = %q", getLiteralHead) + } + gotScript := readLine() + if gotScript != scriptContent { + t.Fatalf("GETSCRIPT content = %q, want %q", gotScript, scriptContent) + } + if getOk := readLine(); !strings.HasPrefix(getOk, "OK") { + t.Fatalf("GETSCRIPT ok = %q", getOk) + } + + // 7. LISTSCRIPTS + sendCmd("LISTSCRIPTS") + listLine := readLine() + if !strings.Contains(listLine, `"my_rules"`) { + t.Fatalf("LISTSCRIPTS line = %q", listLine) + } + if listOk := readLine(); !strings.HasPrefix(listOk, "OK") { + t.Fatalf("LISTSCRIPTS ok = %q", listOk) + } + + // 8. SETACTIVE + sendCmd("SETACTIVE \"my_rules\"") + if setActiveOk := readLine(); !strings.HasPrefix(setActiveOk, "OK") { + t.Fatalf("SETACTIVE ok = %q", setActiveOk) + } + + // Verify in DB that script is active + var isActive bool + if err := pool.QueryRow(ctx, `SELECT is_active FROM sieve_scripts WHERE mailbox_id = $1 AND name = 'my_rules'`, mailboxID).Scan(&isActive); err != nil || !isActive { + t.Fatalf("expected is_active=true in DB, err=%v, isActive=%v", err, isActive) + } + + // 9. RENAMESCRIPT + sendCmd("RENAMESCRIPT \"my_rules\" \"active_rules\"") + if renameOk := readLine(); !strings.HasPrefix(renameOk, "OK") { + t.Fatalf("RENAMESCRIPT ok = %q", renameOk) + } + + // 10. HAVESPACE + sendCmd("HAVESPACE \"active_rules\" 500") + if spaceOk := readLine(); !strings.HasPrefix(spaceOk, "OK") { + t.Fatalf("HAVESPACE ok = %q", spaceOk) + } + + // 11. Deactivate & DELETE + sendCmd("SETACTIVE \"\"") + if deactOk := readLine(); !strings.HasPrefix(deactOk, "OK") { + t.Fatalf("deactivate ok = %q", deactOk) + } + + sendCmd("DELETESCRIPT \"active_rules\"") + if delOk := readLine(); !strings.HasPrefix(delOk, "OK") { + t.Fatalf("DELETESCRIPT ok = %q", delOk) + } + + // Verify in DB that script was deleted + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM sieve_scripts WHERE mailbox_id = $1`, mailboxID).Scan(&count); err != nil || count != 0 { + t.Fatalf("expected 0 scripts in DB, got count=%d, err=%v", count, err) + } + + // 12. LOGOUT + sendCmd("LOGOUT") + if logoutOk := readLine(); !strings.HasPrefix(logoutOk, "OK") { + t.Fatalf("LOGOUT ok = %q", logoutOk) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/sieve_repository.go b/services/protocols/internal/infrastructure/postgres/sieve_repository.go new file mode 100644 index 0000000..8ba53a1 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/sieve_repository.go @@ -0,0 +1,120 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// SieveRepository implements port.SieveRepository against PostgreSQL sieve_scripts table. +type SieveRepository struct { + pool *pgxpool.Pool +} + +func NewSieveRepository(pool *pgxpool.Pool) *SieveRepository { + return &SieveRepository{pool: pool} +} + +func (r *SieveRepository) GetScript(ctx context.Context, mailboxID string, name string) (*port.SieveScriptRecord, error) { + var rec port.SieveScriptRecord + err := r.pool.QueryRow(ctx, ` + SELECT id, mailbox_id, name, script, is_active + FROM sieve_scripts + WHERE mailbox_id = $1 AND name = $2 + `, mailboxID, name).Scan(&rec.ID, &rec.MailboxID, &rec.Name, &rec.Script, &rec.IsActive) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get sieve script %s: %w", name, err) + } + return &rec, nil +} + +func (r *SieveRepository) PutScript(ctx context.Context, mailboxID string, name string, script string) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO sieve_scripts (mailbox_id, name, script, is_active) + VALUES ($1, $2, $3, false) + ON CONFLICT (mailbox_id, name) DO UPDATE SET script = EXCLUDED.script + `, mailboxID, name, script) + if err != nil { + return fmt.Errorf("put sieve script %s: %w", name, err) + } + return nil +} + +func (r *SieveRepository) ListScripts(ctx context.Context, mailboxID string) ([]port.SieveScriptRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, mailbox_id, name, script, is_active + FROM sieve_scripts + WHERE mailbox_id = $1 + ORDER BY name ASC + `, mailboxID) + if err != nil { + return nil, fmt.Errorf("list sieve scripts: %w", err) + } + defer rows.Close() + + var out []port.SieveScriptRecord + for rows.Next() { + var rec port.SieveScriptRecord + if err := rows.Scan(&rec.ID, &rec.MailboxID, &rec.Name, &rec.Script, &rec.IsActive); err != nil { + return nil, err + } + out = append(out, rec) + } + return out, rows.Err() +} + +func (r *SieveRepository) SetActiveScript(ctx context.Context, mailboxID string, name string) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, ` + UPDATE sieve_scripts SET is_active = false WHERE mailbox_id = $1 + `, mailboxID); err != nil { + return fmt.Errorf("deactivate sieve scripts: %w", err) + } + + if name != "" { + tag, err := tx.Exec(ctx, ` + UPDATE sieve_scripts SET is_active = true WHERE mailbox_id = $1 AND name = $2 + `, mailboxID, name) + if err != nil { + return fmt.Errorf("activate sieve script %s: %w", name, err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("script %s not found", name) + } + } + + return tx.Commit(ctx) +} + +func (r *SieveRepository) DeleteScript(ctx context.Context, mailboxID string, name string) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM sieve_scripts WHERE mailbox_id = $1 AND name = $2 AND is_active = false + `, mailboxID, name) + if err != nil { + return fmt.Errorf("delete sieve script %s: %w", name, err) + } + return nil +} + +func (r *SieveRepository) RenameScript(ctx context.Context, mailboxID string, oldName string, newName string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE sieve_scripts SET name = $3 WHERE mailbox_id = $1 AND name = $2 + `, mailboxID, oldName, newName) + if err != nil { + return fmt.Errorf("rename sieve script %s to %s: %w", oldName, newName, err) + } + return nil +} diff --git a/services/protocols/internal/infrastructure/postgres/sieve_repository_test.go b/services/protocols/internal/infrastructure/postgres/sieve_repository_test.go new file mode 100644 index 0000000..14b066f --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/sieve_repository_test.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" +) + +func TestSieveRepository_CRUD(t *testing.T) { + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test") + } + + ctx := context.Background() + pool, err := NewPool(ctx, dbURL) + if err != nil { + t.Fatalf("connect pool: %v", err) + } + defer pool.Close() + + domainID := uuid.New() + mailboxID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'sieve.test', 'sieve.test')`, domainID); err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'sieve', 'sieve@sieve.test', 'hash')`, mailboxID, domainID); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + + repo := NewSieveRepository(pool) + + // 1. PutScript + if err := repo.PutScript(ctx, mailboxID.String(), "script1", "keep;"); err != nil { + t.Fatalf("PutScript: %v", err) + } + + // 2. GetScript + rec, err := repo.GetScript(ctx, mailboxID.String(), "script1") + if err != nil || rec == nil || rec.Script != "keep;" { + t.Fatalf("GetScript: rec=%+v, err=%v", rec, err) + } + + // 3. SetActive + if err := repo.SetActiveScript(ctx, mailboxID.String(), "script1"); err != nil { + t.Fatalf("SetActiveScript: %v", err) + } + rec, _ = repo.GetScript(ctx, mailboxID.String(), "script1") + if !rec.IsActive { + t.Fatal("expected is_active = true") + } + + // 4. RenameScript + if err := repo.RenameScript(ctx, mailboxID.String(), "script1", "script1_renamed"); err != nil { + t.Fatalf("RenameScript: %v", err) + } + + // 5. Deactivate and Delete + if err := repo.SetActiveScript(ctx, mailboxID.String(), ""); err != nil { + t.Fatalf("SetActiveScript empty: %v", err) + } + if err := repo.DeleteScript(ctx, mailboxID.String(), "script1_renamed"); err != nil { + t.Fatalf("DeleteScript: %v", err) + } + + // 6. Confirm deleted + rec, _ = repo.GetScript(ctx, mailboxID.String(), "script1_renamed") + if rec != nil { + t.Fatal("expected script to be deleted, got non-nil record") + } +} diff --git a/services/protocols/internal/presentation/managesieve/conn.go b/services/protocols/internal/presentation/managesieve/conn.go new file mode 100644 index 0000000..8a631a1 --- /dev/null +++ b/services/protocols/internal/presentation/managesieve/conn.go @@ -0,0 +1,428 @@ +package managesievepresentation + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net" + "strconv" + "strings" + + "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" +) + +type state int + +const ( + stateAuth state = iota + stateMain +) + +type conn struct { + rawConn net.Conn + reader *bufio.Reader + writer *bufio.Writer + tlsProvider port.CertProvider + + useCase *appusecase.ManageSieveSessionUseCase + + state state + username string + mailboxID string + isTLS bool +} + +func newConn(c net.Conn, useCase *appusecase.ManageSieveSessionUseCase, tlsProvider port.CertProvider) *conn { + return &conn{ + rawConn: c, + reader: bufio.NewReader(c), + writer: bufio.NewWriter(c), + useCase: useCase, + tlsProvider: tlsProvider, + state: stateAuth, + } +} + +func (c *conn) serve() { + defer c.rawConn.Close() + + c.sendCapabilities() + c.writeLine(`OK "LambdaMail ManageSieve ready."`) + + for { + line, err := c.reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + continue + } + + parts := strings.SplitN(line, " ", 2) + cmd := strings.ToUpper(parts[0]) + arg := "" + if len(parts) > 1 { + arg = parts[1] + } + + switch cmd { + case "CAPABILITY": + c.handleCapability() + case "STARTTLS": + if c.handleStartTLS() { + c.sendCapabilities() + c.writeLine(`OK "TLS negotiation complete."`) + } + case "AUTHENTICATE": + c.handleAuthenticate(arg) + case "PUTSCRIPT": + c.handlePutScript(arg) + case "GETSCRIPT": + c.handleGetScript(arg) + case "SETACTIVE": + c.handleSetActive(arg) + case "HAVESPACE": + c.handleHaveSpace(arg) + case "LISTSCRIPTS": + c.handleListScripts() + case "DELETESCRIPT": + c.handleDeleteScript(arg) + case "RENAMESCRIPT": + c.handleRenameScript(arg) + case "CHECKSCRIPT": + c.handleCheckScript(arg) + case "LOGOUT", "QUIT": + c.writeLine(`OK "Logout complete."`) + return + default: + c.writeLine(`NO "Unknown or unsupported command."`) + } + } +} + +func (c *conn) writeLine(msg string) error { + if _, err := c.writer.WriteString(msg + "\r\n"); err != nil { + return err + } + return c.writer.Flush() +} + +func (c *conn) sendCapabilities() { + c.writer.WriteString("\"IMPLEMENTATION\" \"LambdaMail ManageSieve\"\r\n") + c.writer.WriteString("\"SASL\" \"PLAIN\"\r\n") + if !c.isTLS && c.tlsProvider != nil { + c.writer.WriteString("\"STARTTLS\"\r\n") + } + c.writer.WriteString("\"SIEVE\" \"fileinto reject envelope subaddress body imap4flags variables\"\r\n") + c.writer.Flush() +} + +func (c *conn) handleCapability() { + c.sendCapabilities() + c.writeLine(`OK "Capability completed."`) +} + +func (c *conn) handleStartTLS() bool { + if c.isTLS { + c.writeLine(`NO "TLS already active."`) + return false + } + if c.tlsProvider == nil { + c.writeLine(`NO "STARTTLS not supported."`) + return false + } + if err := c.writeLine(`OK "Begin TLS negotiation."`); err != nil { + return false + } + tlsConfig := &tls.Config{ + GetCertificate: c.tlsProvider.GetCertificate, + MinVersion: tls.VersionTLS12, + } + tlsConn := tls.Server(c.rawConn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return false + } + c.rawConn = tlsConn + c.reader = bufio.NewReader(tlsConn) + c.writer = bufio.NewWriter(tlsConn) + c.isTLS = true + return true +} + +func (c *conn) handleAuthenticate(arg string) { + if c.state != stateAuth { + c.writeLine(`NO "Already authenticated."`) + return + } + + parts := strings.Fields(arg) + if len(parts) == 0 || strings.ToUpper(parts[0]) != `"PLAIN"` && strings.ToUpper(parts[0]) != `PLAIN` { + c.writeLine(`NO "Unsupported SASL mechanism."`) + return + } + + var respB64 string + if len(parts) > 1 { + respB64 = parts[1] + } else { + // Challenge response mode + c.writeLine(`""`) + line, err := c.reader.ReadString('\n') + if err != nil { + return + } + respB64 = strings.TrimRight(line, "\r\n") + } + + decoded, err := base64.StdEncoding.DecodeString(respB64) + if err != nil { + c.writeLine(`NO "Invalid base64 payload."`) + return + } + + // SASL PLAIN format: [authzid] \0 authcid \0 passwd + saslParts := bytes.Split(decoded, []byte{0}) + var user, pass string + if len(saslParts) == 3 { + user = string(saslParts[1]) + pass = string(saslParts[2]) + } else if len(saslParts) == 2 { + user = string(saslParts[0]) + pass = string(saslParts[1]) + } else { + c.writeLine(`NO "Malformed SASL PLAIN authentication."`) + return + } + + ctx := context.Background() + mailboxID, err := c.useCase.Login(ctx, user, pass) + if err != nil { + c.writeLine(`NO "Authentication failed."`) + return + } + + c.username = user + c.mailboxID = mailboxID + c.state = stateMain + c.writeLine(`OK "Logged in."`) +} + +func (c *conn) handlePutScript(arg string) { + if c.state != stateMain { + c.writeLine(`NO "Authentication required."`) + return + } + + name, content, err := c.parseNameAndContent(arg) + if err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + ctx := context.Background() + if err := c.useCase.PutScript(ctx, c.mailboxID, name, content); err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + c.writeLine(`OK "PUTSCRIPT completed."`) +} + +func (c *conn) handleGetScript(arg string) { + if c.state != stateMain { + c.writeLine(`NO "Authentication required."`) + return + } + + name := c.parseStringLiteral(arg) + if name == "" { + c.writeLine(`NO "Missing script name."`) + return + } + + ctx := context.Background() + rec, err := c.useCase.GetScript(ctx, c.mailboxID, name) + if err != nil || rec == nil { + c.writeLine(`NO "Script does not exist."`) + return + } + + c.writer.WriteString(fmt.Sprintf("{%d+}\r\n%s\r\n", len(rec.Script), rec.Script)) + c.writeLine(`OK "GETSCRIPT completed."`) +} + +func (c *conn) handleSetActive(arg string) { + if c.state != stateMain { + c.writeLine(`NO "Authentication required."`) + return + } + + name := c.parseStringLiteral(arg) + ctx := context.Background() + if err := c.useCase.SetActive(ctx, c.mailboxID, name); err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + c.writeLine(`OK "SETACTIVE completed."`) +} + +func (c *conn) handleHaveSpace(arg string) { + parts := strings.Fields(arg) + if len(parts) < 2 { + c.writeLine(`NO "Missing arguments for HAVESPACE."`) + return + } + size, err := strconv.Atoi(parts[1]) + if err != nil || size < 0 || size > appusecase.MaxScriptSizeBytes { + c.writeLine(`NO "Quota exceeded."`) + return + } + c.writeLine(`OK "Storage space available."`) +} + +func (c *conn) handleListScripts() { + if c.state != stateMain { + c.writeLine(`NO "Authentication required."`) + return + } + + ctx := context.Background() + scripts, err := c.useCase.ListScripts(ctx, c.mailboxID) + if err != nil { + c.writeLine(`NO "Unable to list scripts."`) + return + } + + for _, s := range scripts { + if s.IsActive { + c.writer.WriteString(fmt.Sprintf("%q ACTIVE\r\n", s.Name)) + } else { + c.writer.WriteString(fmt.Sprintf("%q\r\n", s.Name)) + } + } + c.writeLine(`OK "LISTSCRIPTS completed."`) +} + +func (c *conn) handleDeleteScript(arg string) { + if c.state != stateMain { + c.writeLine(`NO "Authentication required."`) + return + } + + name := c.parseStringLiteral(arg) + if name == "" { + c.writeLine(`NO "Missing script name."`) + return + } + + ctx := context.Background() + if err := c.useCase.DeleteScript(ctx, c.mailboxID, name); err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + c.writeLine(`OK "DELETESCRIPT completed."`) +} + +func (c *conn) handleRenameScript(arg string) { + if c.state != stateMain { + c.writeLine(`NO "Authentication required."`) + return + } + + parts := c.parseTwoStrings(arg) + if len(parts) < 2 { + c.writeLine(`NO "Missing script names for RENAMESCRIPT."`) + return + } + + ctx := context.Background() + if err := c.useCase.RenameScript(ctx, c.mailboxID, parts[0], parts[1]); err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + c.writeLine(`OK "RENAMESCRIPT completed."`) +} + +func (c *conn) handleCheckScript(arg string) { + var content string + var err error + + if strings.HasPrefix(arg, "{") { + _, content, err = c.parseNameAndContent(`"" ` + arg) + } else { + content = c.parseStringLiteral(arg) + } + + if err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + ctx := context.Background() + if err := c.useCase.CheckScript(ctx, content); err != nil { + c.writeLine(fmt.Sprintf(`NO "%v"`, err)) + return + } + + c.writeLine(`OK "CHECKSCRIPT completed."`) +} + +func (c *conn) parseStringLiteral(input string) string { + input = strings.TrimSpace(input) + if strings.HasPrefix(input, `"`) && strings.HasSuffix(input, `"`) && len(input) >= 2 { + return input[1 : len(input)-1] + } + return input +} + +func (c *conn) parseTwoStrings(input string) []string { + fields := strings.Fields(input) + var out []string + for _, f := range fields { + out = append(out, c.parseStringLiteral(f)) + } + return out +} + +func (c *conn) parseNameAndContent(arg string) (string, string, error) { + parts := strings.SplitN(arg, " ", 2) + if len(parts) < 2 { + return "", "", fmt.Errorf("missing parameters") + } + + name := c.parseStringLiteral(parts[0]) + literalHeader := strings.TrimSpace(parts[1]) + + if !strings.HasPrefix(literalHeader, "{") || !strings.HasSuffix(literalHeader, "}") { + // Not literal string - treat literalHeader as quoted string + return name, c.parseStringLiteral(literalHeader), nil + } + + sizeStr := strings.TrimPrefix(literalHeader, "{") + sizeStr = strings.TrimSuffix(sizeStr, "}") + sizeStr = strings.TrimSuffix(sizeStr, "+") + + size, err := strconv.Atoi(sizeStr) + if err != nil || size < 0 { + return "", "", fmt.Errorf("invalid literal size") + } + + buf := make([]byte, size) + if _, err := io.ReadFull(c.reader, buf); err != nil { + return "", "", fmt.Errorf("unable to read script content: %w", err) + } + + // Consume trailing CRLF + c.reader.ReadString('\n') + + return name, string(buf), nil +} diff --git a/services/protocols/internal/presentation/managesieve/conn_test.go b/services/protocols/internal/presentation/managesieve/conn_test.go new file mode 100644 index 0000000..3e6ac74 --- /dev/null +++ b/services/protocols/internal/presentation/managesieve/conn_test.go @@ -0,0 +1,243 @@ +package managesievepresentation + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "net" + "strings" + "testing" + + "github.com/alexedwards/argon2id" + "github.com/google/uuid" + + "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" +) + +type fakeAuthRepository struct { + byAddress map[string]port.MailboxAuth +} + +func (f *fakeAuthRepository) FindByAddress(_ context.Context, address string) (*port.MailboxAuth, error) { + rec, ok := f.byAddress[address] + if !ok { + return nil, nil + } + return &rec, nil +} + +type fakeSieveRepository struct { + scripts map[string]map[string]port.SieveScriptRecord +} + +func newFakeSieveRepository() *fakeSieveRepository { + return &fakeSieveRepository{scripts: make(map[string]map[string]port.SieveScriptRecord)} +} + +func (f *fakeSieveRepository) GetScript(_ context.Context, mailboxID, name string) (*port.SieveScriptRecord, error) { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil, nil + } + rec, ok := mb[name] + if !ok { + return nil, nil + } + return &rec, nil +} + +func (f *fakeSieveRepository) PutScript(_ context.Context, mailboxID, name, script string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + mb = make(map[string]port.SieveScriptRecord) + f.scripts[mailboxID] = mb + } + existing, exists := mb[name] + isActive := false + if exists { + isActive = existing.IsActive + } + mb[name] = port.SieveScriptRecord{ + ID: uuid.New().String(), + MailboxID: mailboxID, + Name: name, + Script: script, + IsActive: isActive, + } + return nil +} + +func (f *fakeSieveRepository) ListScripts(_ context.Context, mailboxID string) ([]port.SieveScriptRecord, error) { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil, nil + } + var out []port.SieveScriptRecord + for _, rec := range mb { + out = append(out, rec) + } + return out, nil +} + +func (f *fakeSieveRepository) SetActiveScript(_ context.Context, mailboxID, name string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil + } + for k, rec := range mb { + rec.IsActive = (k == name) + mb[k] = rec + } + return nil +} + +func (f *fakeSieveRepository) DeleteScript(_ context.Context, mailboxID, name string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil + } + delete(mb, name) + return nil +} + +func (f *fakeSieveRepository) RenameScript(_ context.Context, mailboxID, oldName, newName string) error { + mb, ok := f.scripts[mailboxID] + if !ok { + return nil + } + rec := mb[oldName] + delete(mb, oldName) + rec.Name = newName + mb[newName] = rec + return nil +} + +func setupManageSieveServer(t *testing.T) (net.Listener, func()) { + t.Helper() + password := "correct horse battery staple" + hash, err := argon2id.CreateHash(password, argon2id.DefaultParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + mailboxID := uuid.New() + + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{ + "user@example.test": {ID: mailboxID, PasswordHash: hash}, + }} + sieveRepo := newFakeSieveRepository() + + uc := appusecase.NewManageSieveSessionUseCase(auth, sieveRepo) + srv := NewServer("127.0.0.1:0", uc, nil) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go srv.Serve(ln) + + return ln, func() { + ln.Close() + } +} + +func TestManageSieveServer_FullLifecycle(t *testing.T) { + ln, cleanup := setupManageSieveServer(t) + defer cleanup() + + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + + reader := bufio.NewReader(c) + writer := bufio.NewWriter(c) + + readLine := func() string { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read: %v", err) + } + return strings.TrimRight(line, "\r\n") + } + + sendCmd := func(cmd string) { + writer.WriteString(cmd + "\r\n") + writer.Flush() + } + + // 1. Read greeting capabilities + for { + l := readLine() + if strings.HasPrefix(l, "OK") { + break + } + } + + // 2. CAPABILITY + sendCmd("CAPABILITY") + for { + l := readLine() + if strings.HasPrefix(l, "OK") { + break + } + } + + // 3. AUTHENTICATE PLAIN + saslPayload := base64.StdEncoding.EncodeToString([]byte("\x00user@example.test\x00correct horse battery staple")) + sendCmd(fmt.Sprintf("AUTHENTICATE \"PLAIN\" %s", saslPayload)) + if authResp := readLine(); !strings.HasPrefix(authResp, "OK") { + t.Fatalf("AUTH resp = %q, want OK", authResp) + } + + // 4. CHECKSCRIPT + scriptContent := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }` + sendCmd(fmt.Sprintf("CHECKSCRIPT {%d+}\r\n%s", len(scriptContent), scriptContent)) + if checkResp := readLine(); !strings.HasPrefix(checkResp, "OK") { + t.Fatalf("CHECKSCRIPT resp = %q", checkResp) + } + + // 5. PUTSCRIPT + sendCmd(fmt.Sprintf("PUTSCRIPT \"my_rules\" {%d+}\r\n%s", len(scriptContent), scriptContent)) + if putResp := readLine(); !strings.HasPrefix(putResp, "OK") { + t.Fatalf("PUTSCRIPT resp = %q", putResp) + } + + // 6. GETSCRIPT + sendCmd("GETSCRIPT \"my_rules\"") + getLiteralHead := readLine() + if !strings.HasPrefix(getLiteralHead, "{") { + t.Fatalf("GETSCRIPT literal head = %q", getLiteralHead) + } + getScriptContent := readLine() + if getScriptContent != scriptContent { + t.Errorf("GETSCRIPT content = %q, want %q", getScriptContent, scriptContent) + } + if getOk := readLine(); !strings.HasPrefix(getOk, "OK") { + t.Fatalf("GETSCRIPT term = %q", getOk) + } + + // 7. LISTSCRIPTS + sendCmd("LISTSCRIPTS") + listLine := readLine() + if !strings.Contains(listLine, `"my_rules"`) { + t.Fatalf("LISTSCRIPTS line = %q", listLine) + } + if listOk := readLine(); !strings.HasPrefix(listOk, "OK") { + t.Fatalf("LISTSCRIPTS term = %q", listOk) + } + + // 8. SETACTIVE + sendCmd("SETACTIVE \"my_rules\"") + if setActiveOk := readLine(); !strings.HasPrefix(setActiveOk, "OK") { + t.Fatalf("SETACTIVE term = %q", setActiveOk) + } + + // 9. LOGOUT + sendCmd("LOGOUT") + if logoutOk := readLine(); !strings.HasPrefix(logoutOk, "OK") { + t.Fatalf("LOGOUT term = %q", logoutOk) + } +} diff --git a/services/protocols/internal/presentation/managesieve/server.go b/services/protocols/internal/presentation/managesieve/server.go new file mode 100644 index 0000000..7698acd --- /dev/null +++ b/services/protocols/internal/presentation/managesieve/server.go @@ -0,0 +1,53 @@ +package managesievepresentation + +import ( + "net" + + "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" +) + +type Server struct { + addr string + useCase *appusecase.ManageSieveSessionUseCase + tlsProvider port.CertProvider + listener net.Listener +} + +func NewServer(addr string, useCase *appusecase.ManageSieveSessionUseCase, tlsProvider port.CertProvider) *Server { + return &Server{ + addr: addr, + useCase: useCase, + tlsProvider: tlsProvider, + } +} + +func (s *Server) ListenAndServe() error { + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return err + } + s.listener = ln + return s.Serve(ln) +} + +func (s *Server) Serve(ln net.Listener) error { + s.listener = ln + for { + c, err := ln.Accept() + if err != nil { + return err + } + go func(rawConn net.Conn) { + connHandler := newConn(rawConn, s.useCase, s.tlsProvider) + connHandler.serve() + }(c) + } +} + +func (s *Server) Close() error { + if s.listener != nil { + return s.listener.Close() + } + return nil +}