diff --git a/services/protocols/internal/e2e/f2_complete_e2e_test.go b/services/protocols/internal/e2e/f2_complete_e2e_test.go new file mode 100644 index 0000000..05dbc60 --- /dev/null +++ b/services/protocols/internal/e2e/f2_complete_e2e_test.go @@ -0,0 +1,463 @@ +package e2e + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alexedwards/argon2id" + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-imap/v2/imapserver" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + + "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/infrastructure/diskstorage" + "lambdamail/protocols/internal/infrastructure/postgres" + imappresentation "lambdamail/protocols/internal/presentation/imap" + managesievepresentation "lambdamail/protocols/internal/presentation/managesieve" + pop3presentation "lambdamail/protocols/internal/presentation/pop3" +) + +func TestF2CompleteSuiteEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("complete F2 e2e test skipped in -short mode") + } + ctx := context.Background() + + // 1. Embedded Postgres Setup + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54335). + 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:54335/postgres?sslmode=disable" + pool, err := postgres.NewPool(ctx, dbURL) + if err != nil { + t.Fatalf("connect pool: %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) + } + + // 2. Seed domain, mailbox, INBOX, Archive + 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" + inboxID := "00000000-0000-0000-0000-000000000003" + archiveID := "00000000-0000-0000-0000-000000000004" + + 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) + } + if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, inboxID, mailboxID); err != nil { + t.Fatalf("seed INBOX: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'Archive', 'archive')`, archiveID, mailboxID); err != nil { + t.Fatalf("seed Archive: %v", err) + } + + // 3. Initialize Use Cases & Protocol Servers + mailboxRepo := postgres.NewMailboxRepository(pool) + authRepo := postgres.NewAuthRepository(pool) + imapFolderRepo := postgres.NewImapFolderRepository(pool) + messageQueryRepo := postgres.NewMessageQueryRepository(pool) + flagRepo := postgres.NewFlagRepository(pool) + expungeRepo := postgres.NewExpungeRepository(pool) + copyRepo := postgres.NewCopyRepository(pool) + sieveRepo := postgres.NewSieveRepository(pool) + spool := t.TempDir() + blobStorage := diskstorage.NewLocalDiskBlobStorage(pool, spool) + blobReader := diskstorage.NewLocalDiskBlobReader(pool) + + inboundUC := appusecase.NewProcessInboundEmailUseCase(mailboxRepo, blobStorage, postgres.NewInboundMessageRepository(pool)) + imapUC := appusecase.NewImapSessionUseCase(authRepo, imapFolderRepo, messageQueryRepo, flagRepo, blobReader, expungeRepo, copyRepo) + inboundUC.SetTrackerManager(imapUC.GetTrackerManager(), imapFolderRepo) + + pop3UC := appusecase.NewPop3SessionUseCase(authRepo, imapFolderRepo, messageQueryRepo, blobReader, expungeRepo) + sieveUC := appusecase.NewManageSieveSessionUseCase(authRepo, sieveRepo) + + // --- Deliver 2 initial emails via SMTP inbound --------------------------- + recipientRecord, err := mailboxRepo.FindActiveByAddress(ctx, "user@example.test") + if err != nil || recipientRecord == nil { + t.Fatalf("find recipient: %v", err) + } + msg1Raw := "From: sender1@remote.test\r\nTo: user@example.test\r\nSubject: Important message\r\n\r\n.Dot line test\r\nHello World\r\n" + if err := inboundUC.Handle(ctx, appusecase.ProcessInboundEmailInput{ + Sender: "sender1@remote.test", + Recipients: []port.MailboxRecord{*recipientRecord}, + RecipientAddresses: []string{"user@example.test"}, + Body: bytes.NewBufferString(msg1Raw), + }); err != nil { + t.Fatalf("deliver msg1: %v", err) + } + + msg2Raw := "From: sender2@remote.test\r\nTo: user@example.test\r\nSubject: Newsletter\r\n\r\nUnsubscribe here.\r\n" + if err := inboundUC.Handle(ctx, appusecase.ProcessInboundEmailInput{ + Sender: "sender2@remote.test", + Recipients: []port.MailboxRecord{*recipientRecord}, + RecipientAddresses: []string{"user@example.test"}, + Body: bytes.NewBufferString(msg2Raw), + }); err != nil { + t.Fatalf("deliver msg2: %v", err) + } + + // Launch IMAP Server + imapServer := imapserver.New(&imapserver.Options{ + NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return imappresentation.NewSession(c, imapUC) + }, + Caps: imap.CapSet{ + imap.CapIMAP4rev1: {}, + imap.CapMove: {}, + imap.CapUIDPlus: {}, + imap.CapIdle: {}, + imap.CapCondStore: {}, + }, + InsecureAuth: true, + }) + imapLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("imap listen: %v", err) + } + go imapServer.Serve(imapLn) + defer imapServer.Close() + + // Launch POP3 Server + pop3Ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("pop3 listen: %v", err) + } + pop3Server := pop3presentation.NewServer(pop3Ln.Addr().String(), pop3UC, nil) + go pop3Server.Serve(pop3Ln) + defer pop3Server.Close() + + // Launch ManageSieve Server + sieveLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("sieve listen: %v", err) + } + sieveServer := managesievepresentation.NewServer(sieveLn.Addr().String(), sieveUC, nil) + go sieveServer.Serve(sieveLn) + defer sieveServer.Close() + + // ========================================================================= + // SECTION A: IMAP E2E (SELECT, FETCH, STORE, SEARCH, IDLE, CONDSTORE, MOVE) + // ========================================================================= + mailboxUpdates := make(chan *imapclient.UnilateralDataMailbox, 10) + clientA, err := imapclient.DialInsecure(imapLn.Addr().String(), &imapclient.Options{ + UnilateralDataHandler: &imapclient.UnilateralDataHandler{ + Mailbox: func(data *imapclient.UnilateralDataMailbox) { + mailboxUpdates <- data + }, + }, + }) + if err != nil { + t.Fatalf("dial clientA: %v", err) + } + defer clientA.Close() + + if err := clientA.Login("user@example.test", password).Wait(); err != nil { + t.Fatalf("clientA login: %v", err) + } + + selectData, err := clientA.Select("INBOX", nil).Wait() + if err != nil || selectData.NumMessages != 2 { + t.Fatalf("clientA select INBOX: err=%v, numMsg=%d", err, selectData.NumMessages) + } + + // STORE +FLAGS \Flagged on Message 1 + if err := clientA.Store(imap.SeqSetNum(1), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagFlagged}}, nil).Close(); err != nil { + t.Fatalf("clientA store flagged: %v", err) + } + + // SEARCH FLAGGED + searchRes, err := clientA.Search(&imap.SearchCriteria{Flag: []imap.Flag{imap.FlagFlagged}}, nil).Wait() + if err != nil || len(searchRes.AllSeqNums()) != 1 { + t.Fatalf("clientA search flagged: err=%v, count=%d", err, len(searchRes.AllSeqNums())) + } + + // SEARCH by Subject header + headerRes, err := clientA.Search(&imap.SearchCriteria{Header: []imap.SearchCriteriaHeaderField{{Key: "Subject", Value: "Important"}}}, nil).Wait() + if err != nil || len(headerRes.AllSeqNums()) != 1 { + t.Fatalf("clientA search header: err=%v, count=%d", err, len(headerRes.AllSeqNums())) + } + + // SEARCH by Body text + textRes, err := clientA.Search(&imap.SearchCriteria{Text: []string{"World"}}, nil).Wait() + if err != nil || len(textRes.AllSeqNums()) != 1 { + t.Fatalf("clientA search text: err=%v, count=%d", err, len(textRes.AllSeqNums())) + } + + // Start IDLE on Client A + idleCmd, err := clientA.Idle() + if err != nil { + t.Fatalf("clientA idle start: %v", err) + } + + // Concurrently deliver a 3rd message while Client A is in IDLE + msg3Raw := "From: sender3@remote.test\r\nTo: user@example.test\r\nSubject: Realtime message\r\n\r\nIDLE push notification test\r\n" + go func() { + time.Sleep(100 * time.Millisecond) + _ = inboundUC.Handle(context.Background(), appusecase.ProcessInboundEmailInput{ + Sender: "sender3@remote.test", + Recipients: []port.MailboxRecord{*recipientRecord}, + RecipientAddresses: []string{"user@example.test"}, + Body: bytes.NewBufferString(msg3Raw), + }) + }() + + // Verify Client A receives IDLE notification + select { + case update := <-mailboxUpdates: + if update == nil || update.NumMessages == nil || *update.NumMessages != 3 { + t.Fatalf("expected IDLE update.NumMessages=3, got %+v", update) + } + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for IDLE update on Client A") + } + + if err := idleCmd.Close(); err != nil { + t.Fatalf("clientA idle stop: %v", err) + } + + // MOVE Message 3 to Archive folder (RFC 6851) + if _, err := clientA.Move(imap.SeqSetNum(3), "Archive").Wait(); err != nil { + t.Fatalf("clientA move: %v", err) + } + + archiveSelect, err := clientA.Select("Archive", nil).Wait() + if err != nil || archiveSelect.NumMessages != 1 { + t.Fatalf("select Archive: err=%v, numMsg=%d", err, archiveSelect.NumMessages) + } + + // Re-select INBOX (now 2 messages remaining: msg1 and msg2) + inboxSelect, err := clientA.Select("INBOX", nil).Wait() + if err != nil || inboxSelect.NumMessages != 2 { + t.Fatalf("re-select INBOX: err=%v, numMsg=%d", err, inboxSelect.NumMessages) + } + clientA.Close() + + // ========================================================================= + // SECTION B: POP3 E2E (STAT, LIST, UIDL, TOP, RETR, DELE, QUIT) + // ========================================================================= + pop3Conn, err := net.Dial("tcp", pop3Ln.Addr().String()) + if err != nil { + t.Fatalf("dial POP3: %v", err) + } + defer pop3Conn.Close() + + pop3Reader := bufio.NewReader(pop3Conn) + pop3Writer := bufio.NewWriter(pop3Conn) + + readLinePop3 := func() string { + line, err := pop3Reader.ReadString('\n') + if err != nil { + t.Fatalf("pop3 read: %v", err) + } + return strings.TrimRight(line, "\r\n") + } + sendCmdPop3 := func(cmd string) { + pop3Writer.WriteString(cmd + "\r\n") + pop3Writer.Flush() + } + + if g := readLinePop3(); !strings.HasPrefix(g, "+OK") { + t.Fatalf("POP3 greeting = %q", g) + } + + sendCmdPop3("CAPA") + readLinePop3() // +OK + for { + if l := readLinePop3(); l == "." { + break + } + } + + sendCmdPop3("USER user@example.test") + readLinePop3() + sendCmdPop3("PASS correct horse battery staple") + if passResp := readLinePop3(); !strings.HasPrefix(passResp, "+OK") { + t.Fatalf("POP3 PASS resp = %q", passResp) + } + + sendCmdPop3("STAT") + if statResp := readLinePop3(); !strings.HasPrefix(statResp, "+OK 2 ") { + t.Fatalf("POP3 STAT = %q, expected 2 messages", statResp) + } + + sendCmdPop3("LIST 1") + if listResp := readLinePop3(); !strings.HasPrefix(listResp, "+OK 1 ") { + t.Fatalf("POP3 LIST = %q", listResp) + } + + sendCmdPop3("UIDL 1") + if uidlResp := readLinePop3(); !strings.HasPrefix(uidlResp, "+OK 1 ") { + t.Fatalf("POP3 UIDL = %q", uidlResp) + } + + // TOP 1 1 verifying dot-stuffing ..Dot line test + sendCmdPop3("TOP 1 1") + if topHead := readLinePop3(); !strings.HasPrefix(topHead, "+OK") { + t.Fatalf("POP3 TOP head = %q", topHead) + } + var topLines []string + for { + l := readLinePop3() + if l == "." { + break + } + topLines = append(topLines, l) + } + if len(topLines) < 5 || topLines[4] != "..Dot line test" { + t.Fatalf("POP3 dot-stuffed TOP lines = %v", topLines) + } + + sendCmdPop3("RETR 1") + if retrHead := readLinePop3(); !strings.HasPrefix(retrHead, "+OK") { + t.Fatalf("POP3 RETR head = %q", retrHead) + } + for { + if l := readLinePop3(); l == "." { + break + } + } + + sendCmdPop3("DELE 1") + if deleResp := readLinePop3(); !strings.HasPrefix(deleResp, "+OK") { + t.Fatalf("POP3 DELE = %q", deleResp) + } + + sendCmdPop3("QUIT") + if quitResp := readLinePop3(); !strings.HasPrefix(quitResp, "+OK") { + t.Fatalf("POP3 QUIT = %q", quitResp) + } + + time.Sleep(100 * time.Millisecond) + + // Verify INBOX messages marked expunged in Postgres (msg3 moved + msg1 DELE'd) + var expungedCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM email_messages WHERE folder_id = $1 AND expunged_at IS NOT NULL`, inboxID).Scan(&expungedCount); err != nil || expungedCount != 2 { + t.Fatalf("expected 2 expunged messages in INBOX, got count=%d, err=%v", expungedCount, err) + } + + // ========================================================================= + // SECTION C: MANAGESIEVE E2E (CAPABILITY, AUTH, CHECKSCRIPT, PUT, LIST, DELETE) + // ========================================================================= + sieveConn, err := net.Dial("tcp", sieveLn.Addr().String()) + if err != nil { + t.Fatalf("dial ManageSieve: %v", err) + } + defer sieveConn.Close() + + sieveReader := bufio.NewReader(sieveConn) + sieveWriter := bufio.NewWriter(sieveConn) + + readLineSieve := func() string { + line, err := sieveReader.ReadString('\n') + if err != nil { + t.Fatalf("sieve read: %v", err) + } + return strings.TrimRight(line, "\r\n") + } + sendCmdSieve := func(cmd string) { + sieveWriter.WriteString(cmd + "\r\n") + sieveWriter.Flush() + } + + for { + if l := readLineSieve(); strings.HasPrefix(l, "OK") { + break + } + } + + sendCmdSieve("CAPABILITY") + for { + if l := readLineSieve(); strings.HasPrefix(l, "OK") { + break + } + } + + saslPayload := base64.StdEncoding.EncodeToString([]byte("\x00user@example.test\x00correct horse battery staple")) + sendCmdSieve(fmt.Sprintf("AUTHENTICATE \"PLAIN\" %s", saslPayload)) + if authResp := readLineSieve(); !strings.HasPrefix(authResp, "OK") { + t.Fatalf("ManageSieve AUTH = %q", authResp) + } + + sieveCode := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }` + sendCmdSieve(fmt.Sprintf("CHECKSCRIPT {%d+}\r\n%s", len(sieveCode), sieveCode)) + if checkResp := readLineSieve(); !strings.HasPrefix(checkResp, "OK") { + t.Fatalf("CHECKSCRIPT = %q", checkResp) + } + + sendCmdSieve(fmt.Sprintf("PUTSCRIPT \"auto_rules\" {%d+}\r\n%s", len(sieveCode), sieveCode)) + if putResp := readLineSieve(); !strings.HasPrefix(putResp, "OK") { + t.Fatalf("PUTSCRIPT = %q", putResp) + } + + sendCmdSieve("GETSCRIPT \"auto_rules\"") + readLineSieve() // literal head + gotCode := readLineSieve() + if gotCode != sieveCode { + t.Fatalf("GETSCRIPT code = %q, want %q", gotCode, sieveCode) + } + readLineSieve() // OK + + sendCmdSieve("SETACTIVE \"auto_rules\"") + if activeResp := readLineSieve(); !strings.HasPrefix(activeResp, "OK") { + t.Fatalf("SETACTIVE = %q", activeResp) + } + + // Verify RFC 5804 ยง2.7: DELETESCRIPT on active script MUST fail + sendCmdSieve("DELETESCRIPT \"auto_rules\"") + if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") { + t.Fatalf("expected NO for deleting active script, got %q", delActiveResp) + } + + sendCmdSieve("SETACTIVE \"\"") + readLineSieve() // OK + + sendCmdSieve("DELETESCRIPT \"auto_rules\"") + if delOk := readLineSieve(); !strings.HasPrefix(delOk, "OK") { + t.Fatalf("DELETESCRIPT = %q", delOk) + } + + sendCmdSieve("LOGOUT") + if logoutResp := readLineSieve(); !strings.HasPrefix(logoutResp, "OK") { + t.Fatalf("LOGOUT = %q", logoutResp) + } + + // Final DB verification: sieve_scripts table should be empty + var sieveCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM sieve_scripts WHERE mailbox_id = $1`, mailboxID).Scan(&sieveCount); err != nil || sieveCount != 0 { + t.Fatalf("expected 0 sieve scripts in DB, got count=%d, err=%v", sieveCount, err) + } +}