diff --git a/go.mod b/go.mod index 75616fa..4115660 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module email-mcp go 1.25.0 + +require github.com/godbus/dbus/v5 v5.2.2 + +require golang.org/x/sys v0.27.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5334971 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/cli/app.go b/internal/cli/app.go index 190ac01..943286c 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -2,11 +2,13 @@ package cli import ( "context" + "errors" "fmt" "io" "os" "email-mcp/internal/secretstore" + "email-mcp/internal/secretstore/kwallet" ) type MCPRunner interface { @@ -68,7 +70,7 @@ func (a *App) runSetup(ctx context.Context) error { return err } if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil { - return err + return mapAppError(err) } return nil } @@ -77,5 +79,24 @@ func (a *App) runMCP(ctx context.Context) error { if a.runner == nil { return fmt.Errorf("mcp runner is not configured") } - return a.runner.Run(ctx) + return mapAppError(a.runner.Run(ctx)) +} + +func mapAppError(err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, kwallet.ErrKWalletUnavailable): + return fmt.Errorf("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running") + case errors.Is(err, kwallet.ErrKWalletDisabled): + return fmt.Errorf("kwallet is disabled in this KDE session") + case errors.Is(err, kwallet.ErrKWalletOpenFailed): + return fmt.Errorf("kwallet could not be opened; unlock the wallet and try again") + case errors.Is(err, kwallet.ErrCredentialNotFound): + return fmt.Errorf("credentials not configured; run `email-mcp setup`") + default: + return err + } } diff --git a/internal/cli/app_task6_test.go b/internal/cli/app_task6_test.go new file mode 100644 index 0000000..79ec139 --- /dev/null +++ b/internal/cli/app_task6_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "email-mcp/internal/secretstore" + "email-mcp/internal/secretstore/kwallet" +) + +type errorStoreStub struct { + err error +} + +func (s errorStoreStub) Save(context.Context, string, secretstore.Credential) error { + return s.err +} + +func (s errorStoreStub) Load(context.Context, string) (secretstore.Credential, error) { + return secretstore.Credential{}, s.err +} + +func TestAppRunSetupMapsUnavailableWalletError(t *testing.T) { + app := NewAppWithDependencies(&promptStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + }, errorStoreStub{ + err: fmt.Errorf("%w: session bus missing", kwallet.ErrKWalletUnavailable), + }, nil, &bytes.Buffer{}) + + err := app.Run([]string{"setup"}) + if err == nil { + t.Fatal("expected setup to fail") + } + if !strings.Contains(strings.ToLower(err.Error()), "kwallet is not available") { + t.Fatalf("expected mapped kwallet error, got %v", err) + } +} + +func TestMapAppErrorMapsMissingCredentialError(t *testing.T) { + err := mapAppError(fmt.Errorf("%w: missing entry", kwallet.ErrCredentialNotFound)) + if err == nil { + t.Fatal("expected mapped error") + } + if !strings.Contains(err.Error(), "run `email-mcp setup`") { + t.Fatalf("expected setup guidance, got %v", err) + } +} + +func TestMapAppErrorLeavesUnknownErrorsUntouched(t *testing.T) { + wantErr := errors.New("boom") + + err := mapAppError(wantErr) + if !errors.Is(err, wantErr) { + t.Fatalf("expected original error, got %v", err) + } +} diff --git a/internal/secretstore/kwallet/client.go b/internal/secretstore/kwallet/client.go new file mode 100644 index 0000000..4f3aab1 --- /dev/null +++ b/internal/secretstore/kwallet/client.go @@ -0,0 +1,308 @@ +package kwallet + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/godbus/dbus/v5" +) + +const ( + kwalletInterface = "org.kde.KWallet" + kwalletAppID = "email-mcp" + kwalletFolderName = "email-mcp" +) + +var ( + ErrKWalletUnavailable = errors.New("kwallet is not available") + ErrKWalletDisabled = errors.New("kwallet is disabled") + ErrKWalletOpenFailed = errors.New("kwallet could not be opened") + ErrCredentialNotFound = errors.New("credentials not configured") +) + +type ClientImpl struct { + probe func(context.Context) error + open func(context.Context) error + read func(context.Context, string) ([]byte, error) + write func(context.Context, string, []byte) error +} + +type dbusConnection interface { + Object(dest string, path dbus.ObjectPath) dbusObject +} + +type dbusObject interface { + CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call +} + +type kwalletService struct { + name string + path dbus.ObjectPath +} + +type walletSession struct { + connect func() (dbusConnection, error) + + services []kwalletService + object dbusObject + handle int32 +} + +type sessionBusConnection struct { + conn *dbus.Conn +} + +type sessionBusObject struct { + object dbus.BusObject +} + +func NewDefaultWalletClient() Client { + return newClientImpl(newWalletSession(defaultSessionBusConnection)) +} + +func newClientImpl(session *walletSession) ClientImpl { + return ClientImpl{ + probe: session.probe, + open: session.open, + read: session.readEntry, + write: session.writeEntry, + } +} + +func newWalletSession(connect func() (dbusConnection, error)) *walletSession { + return &walletSession{ + connect: connect, + services: []kwalletService{ + {name: "org.kde.kwalletd6", path: "/modules/kwalletd6"}, + {name: "org.kde.kwalletd5", path: "/modules/kwalletd5"}, + }, + } +} + +func defaultSessionBusConnection() (dbusConnection, error) { + conn, err := dbus.SessionBus() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, err) + } + return sessionBusConnection{conn: conn}, nil +} + +func (c sessionBusConnection) Object(dest string, path dbus.ObjectPath) dbusObject { + return sessionBusObject{object: c.conn.Object(dest, path)} +} + +func (o sessionBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call { + return o.object.CallWithContext(ctx, method, flags, args...) +} + +func (c ClientImpl) IsAvailable(ctx context.Context) error { + if c.probe == nil { + return ErrKWalletUnavailable + } + return c.probe(ctx) +} + +func (c ClientImpl) Open(ctx context.Context) error { + if c.open == nil { + return nil + } + return c.open(ctx) +} + +func (c ClientImpl) WriteEntry(ctx context.Context, key string, value []byte) error { + if c.write == nil { + return fmt.Errorf("kwallet write operation is not configured") + } + return c.write(ctx, key, value) +} + +func (c ClientImpl) ReadEntry(ctx context.Context, key string) ([]byte, error) { + if c.read == nil { + return nil, ErrCredentialNotFound + } + return c.read(ctx, key) +} + +func (s *walletSession) probe(ctx context.Context) error { + _, err := s.ensureObject(ctx) + return err +} + +func (s *walletSession) open(ctx context.Context) error { + object, err := s.ensureObject(ctx) + if err != nil { + return err + } + if s.handle != 0 { + return nil + } + + walletName, err := s.walletName(ctx, object) + if err != nil { + return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) + } + + handle, err := s.callInt32(ctx, object, "open", walletName, int64(0), kwalletAppID) + if err != nil { + return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) + } + if handle < 0 { + return fmt.Errorf("%w: handle %d", ErrKWalletOpenFailed, handle) + } + + hasFolder, err := s.callBool(ctx, object, "hasFolder", handle, kwalletFolderName, kwalletAppID) + if err != nil { + return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) + } + if !hasFolder { + created, err := s.callBool(ctx, object, "createFolder", handle, kwalletFolderName, kwalletAppID) + if err != nil { + return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) + } + if !created { + return fmt.Errorf("%w: folder %q could not be created", ErrKWalletOpenFailed, kwalletFolderName) + } + } + + s.handle = handle + return nil +} + +func (s *walletSession) writeEntry(ctx context.Context, key string, value []byte) error { + if err := s.open(ctx); err != nil { + return err + } + + object, err := s.ensureObject(ctx) + if err != nil { + return err + } + + code, err := s.callInt32(ctx, object, "writeEntry", s.handle, kwalletFolderName, key, value, kwalletAppID) + if err != nil { + return fmt.Errorf("kwallet write failed: %w", err) + } + if code != 0 { + return fmt.Errorf("kwallet write failed with code %d", code) + } + return nil +} + +func (s *walletSession) readEntry(ctx context.Context, key string) ([]byte, error) { + if err := s.open(ctx); err != nil { + return nil, err + } + + object, err := s.ensureObject(ctx) + if err != nil { + return nil, err + } + + hasEntry, err := s.callBool(ctx, object, "hasEntry", s.handle, kwalletFolderName, key, kwalletAppID) + if err != nil { + return nil, err + } + if !hasEntry { + return nil, fmt.Errorf("%w: key %q", ErrCredentialNotFound, key) + } + + value, err := s.callBytes(ctx, object, "readEntry", s.handle, kwalletFolderName, key, kwalletAppID) + if err != nil { + return nil, err + } + return value, nil +} + +func (s *walletSession) ensureObject(ctx context.Context) (dbusObject, error) { + if s.object != nil { + return s.object, nil + } + + conn, err := s.connect() + if err != nil { + if errors.Is(err, ErrKWalletUnavailable) { + return nil, err + } + return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, err) + } + + var lastErr error + for _, service := range s.services { + object := conn.Object(service.name, service.path) + enabled, err := s.callBool(ctx, object, "isEnabled") + if err != nil { + lastErr = err + continue + } + if !enabled { + return nil, ErrKWalletDisabled + } + + s.object = object + return s.object, nil + } + + if lastErr == nil { + lastErr = errors.New("no kwallet service responded") + } + return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, lastErr) +} + +func (s *walletSession) walletName(ctx context.Context, object dbusObject) (string, error) { + name, err := s.callString(ctx, object, "networkWallet") + if err != nil { + return "", err + } + name = strings.TrimSpace(name) + if name != "" { + return name, nil + } + + name, err = s.callString(ctx, object, "localWallet") + if err != nil { + return "", err + } + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("kwallet did not report a wallet name") + } + return name, nil +} + +func (s *walletSession) callBool(ctx context.Context, object dbusObject, name string, args ...any) (bool, error) { + var value bool + if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { + return false, err + } + return value, nil +} + +func (s *walletSession) callInt32(ctx context.Context, object dbusObject, name string, args ...any) (int32, error) { + var value int32 + if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { + return 0, err + } + return value, nil +} + +func (s *walletSession) callString(ctx context.Context, object dbusObject, name string, args ...any) (string, error) { + var value string + if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { + return "", err + } + return value, nil +} + +func (s *walletSession) callBytes(ctx context.Context, object dbusObject, name string, args ...any) ([]byte, error) { + var value []byte + if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { + return nil, err + } + return value, nil +} + +func kwalletMethod(name string) string { + return kwalletInterface + "." + name +} diff --git a/internal/secretstore/kwallet/client_test.go b/internal/secretstore/kwallet/client_test.go new file mode 100644 index 0000000..d15989d --- /dev/null +++ b/internal/secretstore/kwallet/client_test.go @@ -0,0 +1,204 @@ +package kwallet + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/godbus/dbus/v5" +) + +type stubCall struct { + body []any + err error +} + +type recordedCall struct { + method string + args []any +} + +type stubObject struct { + responses map[string][]stubCall + calls []recordedCall +} + +func (o *stubObject) CallWithContext(_ context.Context, method string, _ dbus.Flags, args ...any) *dbus.Call { + o.calls = append(o.calls, recordedCall{method: method, args: append([]any(nil), args...)}) + + queue := o.responses[method] + if len(queue) == 0 { + return &dbus.Call{Err: errors.New("unexpected method: " + method)} + } + + response := queue[0] + o.responses[method] = queue[1:] + return &dbus.Call{Body: response.body, Err: response.err} +} + +type stubConnection struct { + objects map[string]*stubObject +} + +func (c *stubConnection) Object(dest string, path dbus.ObjectPath) dbusObject { + return c.objects[dest+"|"+string(path)] +} + +func TestClientIsAvailableReturnsErrorWhenServiceIsMissing(t *testing.T) { + client := newClientImpl(newWalletSession(func() (dbusConnection, error) { + return &stubConnection{ + objects: map[string]*stubObject{ + "org.kde.kwalletd6|/modules/kwalletd6": { + responses: map[string][]stubCall{ + kwalletMethod("isEnabled"): {{err: errors.New("service missing")}}, + }, + }, + "org.kde.kwalletd5|/modules/kwalletd5": { + responses: map[string][]stubCall{ + kwalletMethod("isEnabled"): {{err: errors.New("service missing")}}, + }, + }, + }, + }, nil + })) + + err := client.IsAvailable(context.Background()) + if err == nil { + t.Fatal("expected availability error") + } + if !errors.Is(err, ErrKWalletUnavailable) { + t.Fatalf("expected unavailable error, got %v", err) + } +} + +func TestClientOpenUsesNetworkWalletAndCreatesFolder(t *testing.T) { + walletObject := &stubObject{ + responses: map[string][]stubCall{ + kwalletMethod("isEnabled"): {{body: []any{true}}}, + kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}}, + kwalletMethod("open"): {{body: []any{int32(42)}}}, + kwalletMethod("hasFolder"): {{body: []any{false}}}, + kwalletMethod("createFolder"): {{body: []any{true}}}, + }, + } + client := newClientImpl(newWalletSession(func() (dbusConnection, error) { + return &stubConnection{ + objects: map[string]*stubObject{ + "org.kde.kwalletd6|/modules/kwalletd6": walletObject, + }, + }, nil + })) + + if err := client.Open(context.Background()); err != nil { + t.Fatalf("Open returned error: %v", err) + } + + wantMethods := []string{ + kwalletMethod("isEnabled"), + kwalletMethod("networkWallet"), + kwalletMethod("open"), + kwalletMethod("hasFolder"), + kwalletMethod("createFolder"), + } + gotMethods := make([]string, 0, len(walletObject.calls)) + for _, call := range walletObject.calls { + gotMethods = append(gotMethods, call.method) + } + if !reflect.DeepEqual(gotMethods, wantMethods) { + t.Fatalf("unexpected methods: got %v want %v", gotMethods, wantMethods) + } + openArgs := walletObject.calls[2].args + if len(openArgs) != 3 || openArgs[0] != "kdewallet" || openArgs[1] != int64(0) || openArgs[2] != kwalletAppID { + t.Fatalf("unexpected open args: %#v", openArgs) + } +} + +func TestClientWriteEntryWritesBytesToConfiguredFolder(t *testing.T) { + walletObject := &stubObject{ + responses: map[string][]stubCall{ + kwalletMethod("isEnabled"): {{body: []any{true}}}, + kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}}, + kwalletMethod("open"): {{body: []any{int32(42)}}}, + kwalletMethod("hasFolder"): {{body: []any{true}}}, + kwalletMethod("writeEntry"): {{body: []any{int32(0)}}}, + }, + } + client := newClientImpl(newWalletSession(func() (dbusConnection, error) { + return &stubConnection{ + objects: map[string]*stubObject{ + "org.kde.kwalletd6|/modules/kwalletd6": walletObject, + }, + }, nil + })) + + if err := client.WriteEntry(context.Background(), "default", []byte("payload")); err != nil { + t.Fatalf("WriteEntry returned error: %v", err) + } + + writeArgs := walletObject.calls[len(walletObject.calls)-1].args + wantArgs := []any{int32(42), kwalletFolderName, "default", []byte("payload"), kwalletAppID} + if !reflect.DeepEqual(writeArgs, wantArgs) { + t.Fatalf("unexpected write args: got %#v want %#v", writeArgs, wantArgs) + } +} + +func TestClientReadEntryReturnsCredentialNotFoundWhenEntryIsMissing(t *testing.T) { + walletObject := &stubObject{ + responses: map[string][]stubCall{ + kwalletMethod("isEnabled"): {{body: []any{true}}}, + kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}}, + kwalletMethod("open"): {{body: []any{int32(42)}}}, + kwalletMethod("hasFolder"): {{body: []any{true}}}, + kwalletMethod("hasEntry"): {{body: []any{false}}}, + }, + } + client := newClientImpl(newWalletSession(func() (dbusConnection, error) { + return &stubConnection{ + objects: map[string]*stubObject{ + "org.kde.kwalletd6|/modules/kwalletd6": walletObject, + }, + }, nil + })) + + _, err := client.ReadEntry(context.Background(), "default") + if err == nil { + t.Fatal("expected missing credential error") + } + if !errors.Is(err, ErrCredentialNotFound) { + t.Fatalf("expected missing credential error, got %v", err) + } + for _, call := range walletObject.calls { + if call.method == kwalletMethod("readEntry") { + t.Fatal("did not expect readEntry call when key is missing") + } + } +} + +func TestClientReadEntryReadsStoredPayload(t *testing.T) { + walletObject := &stubObject{ + responses: map[string][]stubCall{ + kwalletMethod("isEnabled"): {{body: []any{true}}}, + kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}}, + kwalletMethod("open"): {{body: []any{int32(42)}}}, + kwalletMethod("hasFolder"): {{body: []any{true}}}, + kwalletMethod("hasEntry"): {{body: []any{true}}}, + kwalletMethod("readEntry"): {{body: []any{[]byte("payload")}}}, + }, + } + client := newClientImpl(newWalletSession(func() (dbusConnection, error) { + return &stubConnection{ + objects: map[string]*stubObject{ + "org.kde.kwalletd6|/modules/kwalletd6": walletObject, + }, + }, nil + })) + + value, err := client.ReadEntry(context.Background(), "default") + if err != nil { + t.Fatalf("ReadEntry returned error: %v", err) + } + if got := string(value); got != "payload" { + t.Fatalf("unexpected payload: %q", got) + } +}