#4: implement F2 sub-project 1 - IMAP core (auth, LIST, SELECT, FETCH, STORE)
Adds a real :143 IMAP listener to the Go protocols service: a mailbox owner authenticates over IMAP (Argon2id against mailboxes.password_hash, enumeration-resistant per PLAN.md section 14.6), lists folders, selects INBOX, fetches messages F1's SMTP path already persisted - including real ENVELOPE/BODY[] built from the actual stored MIME bytes via go-imap/v2's own ExtractEnvelope/ExtractBodySection helpers, not reconstructed from denormalized columns - and sets/reads flags via STORE. - ImapSessionUseCase orchestrates auth, folder listing/selection, message querying, and flag mutation against new Postgres repositories (auth, folders, message query, flags) and a local-disk blob reader. - A github.com/emersion/go-imap/v2 (pinned v2.0.0-beta.8) Backend/Session implements 7 real methods (Login, List, Select, Unselect, Status, Fetch, Store) plus a correct no-op Poll; the other 10 of the interface's 18 methods (Create/Delete/Rename/Subscribe/Unsubscribe/Append/Idle/Expunge/ Search/Copy) are explicit stubs returning a clear "not yet implemented" IMAP error - SEARCH/EXPUNGE/COPY/MOVE, IDLE/CONDSTORE, mailbox management, and APPEND are separate follow-up sub-projects per the design doc's agreed ordering with the user. Verified end-to-end with a real go-imap/v2 imapclient (embedded Postgres, no Docker needed) driving Login -> Select -> Fetch -> Store -> List against a message seeded through F1's real SMTP use case, proving true SMTP-write/IMAP-read interop, not just isolated unit tests. Built via 8 sequential tasks under subagent-driven development, each independently reviewed, plus a final whole-branch review. Two real bugs were found and fixed before merge, both exactly the kind unit tests with fakes cannot catch: - Store's UID-set resolution silently no-op'd on dynamic ranges like "UID STORE 1:*" (the common "mark all as read" operation) after an earlier draft bypassed the folder's real message list; restored to resolve against real messages via the same pattern Fetch already used correctly, with tests proving both dynamic ranges and non-UID STORE. - go-imap/v2's server calls Session.Poll() implicitly before the status response of nearly every authenticated command (FETCH/STORE/SEARCH/ APPEND/COPY), not just IDLE as assumed - a hard-stubbed Poll broke every real command through this path; fixed to a correct no-op. - List had zero test coverage despite being committed scope; added unit tests for wildcard/delimiter matching plus a real E2E LIST assertion. Known follow-up, not blocking: a pre-existing test-fixture hygiene gap (hardcoded content_sha256 blob hashes with no cleanup, shared across several Postgres integration test files including one new to this branch) causes local-dev test collisions on repeated runs against a persistent database - invisible to CI (fresh Postgres per run) but worth a cleanup pass.
This commit is contained in:
1 parent
fc8bbaa3ce
commit
9fa19291d4
25 files changed
+1920
-1
No files matched your search
@@ -0,0 +1,199 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||
)
|
||||
|
||||
func TestImapCoreEndToEnd(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(54330). // distinct from the SMTP E2E test's 54329, so both can run concurrently
|
||||
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:54330/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 a real mailbox with a known password -------------------------
|
||||
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"
|
||||
folderID := "00000000-0000-0000-0000-000000000003"
|
||||
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, 'postmaster', 'postmaster@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')`, folderID, mailboxID); err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
|
||||
// --- Seed a real message via F1's inbound use case (not a raw INSERT - --
|
||||
// --- this proves the IMAP side reads what the SMTP side really wrote) --
|
||||
spool := t.TempDir()
|
||||
inboundUC := appusecase.NewProcessInboundEmailUseCase(
|
||||
postgres.NewMailboxRepository(pool),
|
||||
diskstorage.NewLocalDiskBlobStorage(pool, spool),
|
||||
postgres.NewInboundMessageRepository(pool),
|
||||
)
|
||||
|
||||
mailboxRepo := postgres.NewMailboxRepository(pool)
|
||||
recipientRecord, err := mailboxRepo.FindActiveByAddress(ctx, "postmaster@example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve recipient for seeding: %v", err)
|
||||
}
|
||||
if recipientRecord == nil {
|
||||
t.Fatal("recipient not found - seeding order bug: mailbox must exist before this call")
|
||||
}
|
||||
|
||||
testMessage := "From: sender@remote.example\r\nTo: postmaster@example.test\r\nSubject: imap e2e\r\n\r\nHello via IMAP.\r\n"
|
||||
if err := inboundUC.Handle(ctx, appusecase.ProcessInboundEmailInput{
|
||||
Sender: "sender@remote.example",
|
||||
Recipients: []port.MailboxRecord{*recipientRecord},
|
||||
RecipientAddresses: []string{"postmaster@example.test"},
|
||||
Body: bytes.NewBufferString(testMessage),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed message via SMTP use case: %v", err)
|
||||
}
|
||||
|
||||
// --- Real IMAP server, wired exactly like main.go ----------------------
|
||||
imapUC := appusecase.NewImapSessionUseCase(
|
||||
postgres.NewAuthRepository(pool),
|
||||
postgres.NewImapFolderRepository(pool),
|
||||
postgres.NewMessageQueryRepository(pool),
|
||||
postgres.NewFlagRepository(pool),
|
||||
diskstorage.NewLocalDiskBlobReader(pool),
|
||||
)
|
||||
server := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return imappresentation.NewSession(c, imapUC)
|
||||
},
|
||||
InsecureAuth: true, // no TLS in this test - InsecureAuth lets LOGIN proceed over plaintext
|
||||
})
|
||||
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()
|
||||
|
||||
// --- Real IMAP client drives the session --------------------------------
|
||||
client, err := imapclient.DialInsecure(ln.Addr().String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Login("postmaster@example.test", password).Wait(); err != nil {
|
||||
t.Fatalf("LOGIN: %v", err)
|
||||
}
|
||||
|
||||
selectData, err := client.Select("INBOX", nil).Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("SELECT: %v", err)
|
||||
}
|
||||
if selectData.NumMessages != 1 {
|
||||
t.Errorf("NumMessages = %d, want 1", selectData.NumMessages)
|
||||
}
|
||||
|
||||
// A real IMAP client's first action after LOGIN is typically
|
||||
// LIST "" "*" - drive Session.List through the real imapserver stack
|
||||
// (unlike the unit tests, which can't construct a *imapserver.ListWriter
|
||||
// directly) and confirm INBOX comes back.
|
||||
listEntries, err := client.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("LIST: %v", err)
|
||||
}
|
||||
foundInbox := false
|
||||
for _, entry := range listEntries {
|
||||
if entry.Mailbox == "INBOX" {
|
||||
foundInbox = true
|
||||
}
|
||||
}
|
||||
if !foundInbox {
|
||||
t.Errorf("LIST results = %+v, want an entry for INBOX", listEntries)
|
||||
}
|
||||
|
||||
fetchCmd := client.Fetch(imap.UIDSetNum(1), &imap.FetchOptions{
|
||||
UID: true, Flags: true, Envelope: true,
|
||||
BodySection: []*imap.FetchItemBodySection{{}},
|
||||
})
|
||||
messages, err := fetchCmd.Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("FETCH: %v", err)
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("fetched %d messages, want 1", len(messages))
|
||||
}
|
||||
if messages[0].Envelope == nil || messages[0].Envelope.Subject != "imap e2e" {
|
||||
t.Errorf("Envelope.Subject = %+v, want \"imap e2e\"", messages[0].Envelope)
|
||||
}
|
||||
|
||||
// Client.Store returns *FetchCommand (like Fetch does), not a plain
|
||||
// Command - it has Collect(), not Wait(), since STORE (without SILENT)
|
||||
// streams back the updated FETCH data per RFC 3501.
|
||||
if _, err := client.Store(imap.UIDSetNum(1), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil).Collect(); err != nil {
|
||||
t.Fatalf("STORE: %v", err)
|
||||
}
|
||||
|
||||
fetchAfterStore, err := client.Fetch(imap.UIDSetNum(1), &imap.FetchOptions{Flags: true}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("FETCH after STORE: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range fetchAfterStore[0].Flags {
|
||||
if f == imap.FlagSeen {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("flags after STORE = %v, want to include \\Seen", fetchAfterStore[0].Flags)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user