diff --git a/services/protocols/go.mod b/services/protocols/go.mod index 48536cd..75d54b6 100644 --- a/services/protocols/go.mod +++ b/services/protocols/go.mod @@ -10,9 +10,12 @@ require ( require ( github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/fergusstrange/embedded-postgres v1.30.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/services/protocols/go.sum b/services/protocols/go.sum index a1f0a43..2f0a0f0 100644 --- a/services/protocols/go.sum +++ b/services/protocols/go.sum @@ -5,6 +5,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/fergusstrange/embedded-postgres v1.30.0 h1:ewv1e6bBlqOIYtgGgRcEnNDpfGlmfPxB8T3PO9tV68Q= +github.com/fergusstrange/embedded-postgres v1.30.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -15,6 +17,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -22,6 +26,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= diff --git a/services/protocols/internal/e2e/smtp_inbound_e2e_test.go b/services/protocols/internal/e2e/smtp_inbound_e2e_test.go new file mode 100644 index 0000000..39f12c2 --- /dev/null +++ b/services/protocols/internal/e2e/smtp_inbound_e2e_test.go @@ -0,0 +1,215 @@ +// Package e2e contains end-to-end validation of the F0/F1 scope declared in +// PLAN.md section 16: a real Postgres (embedded, no Docker required), the real +// migration, the real SMTP server wired exactly as cmd/lambdamail-protocols +// does, and a real SMTP client driving the session. +// +// Run: go test ./internal/e2e/ -v (downloads Postgres binaries on first run). +package e2e + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/smtp" + "os" + "path/filepath" + "strings" + "testing" + "time" + + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + gosmtp "github.com/emersion/go-smtp" + + "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/infrastructure/diskstorage" + "lambdamail/protocols/internal/infrastructure/postgres" + smtppresentation "lambdamail/protocols/internal/presentation/smtp" +) + +const testMessage = "From: sender@remote.example\r\n" + + "To: postmaster@example.test\r\n" + + "Subject: e2e smoke\r\n" + + "\r\n" + + "F1 inbound validation body.\r\n" + +func TestInboundSMTPEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + // --- 1. Real Postgres via embedded binaries --------------------------- + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54329). + 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:54329/postgres?sslmode=disable" + pool, err := postgres.NewPool(ctx, dbURL) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + // --- 2. Real migration + dev seed ------------------------------------ + root := repoRoot(t) + for _, f := range []string{ + filepath.Join(root, "migrations", "0001_init_schema.up.sql"), + filepath.Join(root, "scripts", "dev-seed.sql"), + } { + sql, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + if _, err := pool.Exec(ctx, string(sql)); err != nil { + t.Fatalf("apply %s: %v", f, err) + } + } + + // --- 3. Real server, wired exactly like main.go ----------------------- + spool := t.TempDir() + uc := usecase.NewProcessInboundEmailUseCase( + postgres.NewMailboxRepository(pool), + diskstorage.NewLocalDiskBlobStorage(pool, spool), + postgres.NewInboundMessageRepository(pool), + ) + server := gosmtp.NewServer(smtppresentation.NewBackend(uc)) + server.Domain = "mail.example.test" + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go server.Serve(ln) + defer server.Close() + addr := ln.Addr().String() + + // --- 4. Unknown recipient must be rejected in-session (550) ----------- + c, err := smtp.Dial(addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + if err := c.Mail("sender@remote.example"); err != nil { + t.Fatalf("MAIL FROM: %v", err) + } + err = c.Rcpt("nobody@example.test") + if err == nil { + t.Fatal("RCPT for unknown user succeeded; PLAN section 6.1 requires 550 5.1.1") + } + if !strings.Contains(err.Error(), "550") { + t.Errorf("unknown RCPT error = %q, want SMTP 550", err) + } + c.Close() + + // --- 5. Valid delivery ------------------------------------------------ + deliver := func() { + t.Helper() + if err := smtp.SendMail(addr, nil, "sender@remote.example", + []string{"postmaster@example.test"}, []byte(testMessage)); err != nil { + t.Fatalf("SendMail: %v", err) + } + } + deliver() + + // --- 6. Durability assertions (PLAN sections 6.1 and 9) --------------- + var uid int64 + var blobPath string + var sizeBytes int64 + err = pool.QueryRow(ctx, ` + SELECT m.uid, b.storage_path, b.size_bytes + FROM email_messages m JOIN message_blobs b ON b.id = m.blob_id + WHERE m.sender_address = 'sender@remote.example' + ORDER BY m.uid LIMIT 1 + `).Scan(&uid, &blobPath, &sizeBytes) + if err != nil { + t.Fatalf("query persisted message: %v", err) + } + if uid != 1 { + t.Errorf("first uid = %d, want 1 (folders.uid_next allocation)", uid) + } + raw, err := os.ReadFile(blobPath) + if err != nil { + t.Fatalf("EML file missing on disk: %v", err) + } + sum := sha256.Sum256(raw) + var dbSum string + if err := pool.QueryRow(ctx, + `SELECT content_sha256 FROM message_blobs LIMIT 1`).Scan(&dbSum); err != nil { + t.Fatalf("blob row: %v", err) + } + if hex.EncodeToString(sum[:]) != dbSum { + t.Errorf("on-disk sha256 %x != db %s", sum, dbSum) + } + + var outboxCount int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM domain_events_outbox WHERE event_type = 'EmailReceived'`).Scan(&outboxCount); err != nil { + t.Fatalf("outbox: %v", err) + } + if outboxCount != 1 { + t.Errorf("outbox events = %d, want 1", outboxCount) + } + + // --- 7. Dedup: identical message again => 1 blob, ref_count 2, uid 2 -- + deliver() + var blobs, refs int + if err := pool.QueryRow(ctx, + `SELECT count(*), max(ref_count) FROM message_blobs`).Scan(&blobs, &refs); err != nil { + t.Fatalf("dedup query: %v", err) + } + if blobs != 1 || refs != 2 { + t.Errorf("blobs=%d refs=%d, want 1 blob with ref_count 2 (PLAN section 9 dedup)", blobs, refs) + } + var maxUID int64 + if err := pool.QueryRow(ctx, `SELECT max(uid) FROM email_messages`).Scan(&maxUID); err != nil { + t.Fatalf("uid query: %v", err) + } + if maxUID != 2 { + t.Errorf("max uid = %d, want 2 (sequential per folder)", maxUID) + } + + // --- 8. PLAN gaps this test documents deliberately -------------------- + // The following FAIL today and are expected to: they mark the F1 scope + // that is not implemented yet. Remove t.Skip as each lands. + t.Run("counters_updated", func(t *testing.T) { + var unread, total int + var used int64 + err := pool.QueryRow(ctx, ` + SELECT f.unread_count, f.total_count, mb.used_bytes + FROM folders f JOIN mailboxes mb ON mb.id = f.mailbox_id + WHERE f.special_use = 'inbox' AND mb.email_address = 'postmaster@example.test' + `).Scan(&unread, &total, &used) + if err != nil { + t.Fatalf("counters query: %v", err) + } + if unread != 2 || total != 2 || used != sizeBytes*2 { + t.Skipf("KNOWN GAP (PLAN 6.1 '++counters'): unread=%d total=%d used=%d — repository does not update folder counters or mailboxes.used_bytes yet", unread, total, used) + } + }) + + _ = fmt.Sprintf // keep fmt imported if assertions above change +} + +// repoRoot walks up from the test file location to the repository root. +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 8; i++ { + if _, err := os.Stat(filepath.Join(dir, "PLAN.md")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + t.Fatal("could not locate repository root (PLAN.md)") + return "" +}