From e09b4afa0aa1a209a063814014548964c9340014 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Fri, 10 Apr 2026 12:21:58 +0200 Subject: [PATCH] feat: wire email mcp application graph --- internal/cli/app.go | 3 +- internal/cli/app_test.go | 22 +++++- internal/cli/wire.go | 74 +++++++++++------- internal/cli/wire_test.go | 161 +++++++++++++++++++++++++++++++++----- 4 files changed, 208 insertions(+), 52 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 2d166e3..bd8c6e5 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "os" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" @@ -24,7 +23,7 @@ type App struct { } func NewApp() *App { - return NewAppWithDependencies(nil, nil, nil, os.Stderr) + return BuildApp() } func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App { diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 386d1bc..195904f 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -55,7 +55,7 @@ func (r *runnerStub) Run(context.Context) error { } func TestAppRunRejectsUnknownCommand(t *testing.T) { - app := NewApp() + app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{}) err := app.Run([]string{"unknown"}) if err == nil { @@ -64,7 +64,7 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) { } func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { - app := NewApp() + app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{}) err := app.Run(nil) if err == nil { @@ -148,7 +148,7 @@ func TestAppRunMCPDelegatesToRunner(t *testing.T) { } func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { - app := NewApp() + app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{}) tests := []struct { command string @@ -170,3 +170,19 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { }) } } + +func TestNewAppBuildsProductionDependencies(t *testing.T) { + app := NewApp() + if app == nil { + t.Fatal("expected app instance") + } + if app.prompter == nil { + t.Fatal("expected setup prompter to be configured") + } + if app.store == nil { + t.Fatal("expected secret store to be configured") + } + if app.runner == nil { + t.Fatal("expected MCP runner to be configured") + } +} diff --git a/internal/cli/wire.go b/internal/cli/wire.go index a09409f..0b93701 100644 --- a/internal/cli/wire.go +++ b/internal/cli/wire.go @@ -1,44 +1,62 @@ package cli import ( - "context" - "errors" "io" "os" + "email-mcp/internal/imapclient" + "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" + "email-mcp/internal/secretstore/kwallet" ) -var errSecretStoreNotConfigured = errors.New("secret store is not available in this build yet") -var errMCPRunnerNotConfigured = errors.New("mcp runner is not available in this build yet") - -type placeholderStore struct{} - -func (placeholderStore) Save(context.Context, string, secretstore.Credential) error { - return errSecretStoreNotConfigured -} - -func (placeholderStore) Load(context.Context, string) (secretstore.Credential, error) { - return secretstore.Credential{}, errSecretStoreNotConfigured -} - -type placeholderRunner struct{} - -func (placeholderRunner) Run(context.Context) error { - return errMCPRunnerNotConfigured +type runtimeFactories struct { + newPrompter func(io.Reader, io.Writer) SetupPrompter + newWalletClient func() kwallet.Client + newStore func(kwallet.Client) secretstore.Store + newMailService func() mcpserver.MailService + newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner } func BuildApp() *App { - return buildApp(os.Stdin, os.Stdout, os.Stderr) + return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{}) } -func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) *App { - _ = stdout +func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App { + factories = factories.withDefaults() - return NewAppWithDependencies( - NewInteractiveSetupPrompter(stdin, stderr), - placeholderStore{}, - placeholderRunner{}, - stderr, - ) + prompter := factories.newPrompter(stdin, stderr) + store := factories.newStore(factories.newWalletClient()) + mailService := factories.newMailService() + runner := factories.newRunner(store, mailService, stdin, stdout, stderr) + + return NewAppWithDependencies(prompter, store, runner, stderr) +} + +func (f runtimeFactories) withDefaults() runtimeFactories { + if f.newPrompter == nil { + f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter { + return NewInteractiveSetupPrompter(input, output) + } + } + if f.newWalletClient == nil { + f.newWalletClient = kwallet.NewDefaultWalletClient + } + if f.newStore == nil { + f.newStore = func(client kwallet.Client) secretstore.Store { + return kwallet.NewStore(client) + } + } + if f.newMailService == nil { + f.newMailService = func() mcpserver.MailService { + return imapclient.NewService(imapclient.NewDefaultBackend()) + } + } + if f.newRunner == nil { + f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner { + return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut) + } + } + + return f } diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 3929a0b..16e9832 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -2,36 +2,159 @@ package cli import ( "bytes" + "context" + "io" "strings" "testing" + + "email-mcp/internal/imapclient" + "email-mcp/internal/mcpserver" + "email-mcp/internal/secretstore" + "email-mcp/internal/secretstore/kwallet" ) -func TestBuildAppConfiguresSetupCommand(t *testing.T) { - stdin := strings.NewReader("imap.example.com\nalice\nsecret\n") +type walletClientStub struct{} + +func (walletClientStub) IsAvailable(context.Context) error { + return nil +} + +func (walletClientStub) Open(context.Context) error { + return nil +} + +func (walletClientStub) WriteEntry(context.Context, string, []byte) error { + return nil +} + +func (walletClientStub) ReadEntry(context.Context, string) ([]byte, error) { + return nil, nil +} + +type wireStoreStub struct { + saved secretstore.Credential + savedKey string + saveCalled bool +} + +func (s *wireStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error { + s.saveCalled = true + s.savedKey = key + s.saved = cred + return nil +} + +func (s *wireStoreStub) Load(context.Context, string) (secretstore.Credential, error) { + return secretstore.Credential{}, nil +} + +type wireMailServiceStub struct{} + +func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + return nil, nil +} + +func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { + return nil, nil +} + +func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + return imapclient.Message{}, nil +} + +func TestBuildAppWiresSetupAndMCPCommands(t *testing.T) { + stdin := strings.NewReader("unused") stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - app := buildApp(stdin, stdout, stderr) - err := app.Run([]string{"setup"}) - if err == nil { - t.Fatal("expected placeholder store error") + prompter := &promptStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, } - if err.Error() != errSecretStoreNotConfigured.Error() { - t.Fatalf("expected placeholder store error, got %v", err) + store := &wireStoreStub{} + mail := wireMailServiceStub{} + runner := &runnerStub{} + walletClient := &walletClientStub{} + + var gotStoreClient kwallet.Client + var gotRunnerStore secretstore.Store + var gotRunnerMail mcpserver.MailService + + app := buildApp(stdin, stdout, stderr, runtimeFactories{ + newPrompter: func(in io.Reader, errOut io.Writer) SetupPrompter { + if in != stdin { + t.Fatalf("expected stdin to be forwarded to prompter") + } + if errOut != stderr { + t.Fatalf("expected stderr to be forwarded to prompter") + } + return prompter + }, + newWalletClient: func() kwallet.Client { + return walletClient + }, + newStore: func(client kwallet.Client) secretstore.Store { + gotStoreClient = client + return store + }, + newMailService: func() mcpserver.MailService { + return mail + }, + newRunner: func(store secretstore.Store, mail mcpserver.MailService, in io.Reader, out io.Writer, errOut io.Writer) MCPRunner { + gotRunnerStore = store + gotRunnerMail = mail + if in != stdin { + t.Fatalf("expected stdin to be forwarded to runner") + } + if out != stdout { + t.Fatalf("expected stdout to be forwarded to runner") + } + if errOut != stderr { + t.Fatalf("expected stderr to be forwarded to runner") + } + return runner + }, + }) + + if err := app.Run([]string{"setup"}); err != nil { + t.Fatalf("setup returned error: %v", err) } - if got := stderr.String(); got != "IMAP host: Username: Password: " { - t.Fatalf("expected setup prompts on stderr, got %q", got) + if !prompter.called { + t.Fatal("expected setup prompter to be called") + } + if !store.saveCalled { + t.Fatal("expected store Save to be called") + } + if store.savedKey != secretstore.DefaultAccountKey { + t.Fatalf("expected setup to save %q, got %q", secretstore.DefaultAccountKey, store.savedKey) + } + if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" { + t.Fatalf("unexpected saved credential: %#v", store.saved) + } + + if err := app.Run([]string{"mcp"}); err != nil { + t.Fatalf("mcp returned error: %v", err) + } + if !runner.called { + t.Fatal("expected MCP runner to be called") + } + if gotStoreClient != walletClient { + t.Fatal("expected wallet client to be passed to the store factory") + } + if gotRunnerStore != store { + t.Fatal("expected runner to receive the assembled store") + } + if gotRunnerMail != mail { + t.Fatal("expected runner to receive the assembled mail service") } } -func TestBuildAppConfiguresMCPCommand(t *testing.T) { - app := buildApp(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - - err := app.Run([]string{"mcp"}) - if err == nil { - t.Fatal("expected placeholder runner error") - } - if err.Error() != errMCPRunnerNotConfigured.Error() { - t.Fatalf("expected placeholder runner error, got %v", err) +func TestBuildAppReturnsConfiguredApp(t *testing.T) { + app := BuildApp() + if app == nil { + t.Fatal("expected app instance") } }