1279 lines
30 KiB
Markdown
1279 lines
30 KiB
Markdown
# Email MCP Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a local Go binary named `email-mcp` with `setup` and `mcp` modes, storing one IMAP account in KDE Wallet and exposing read-only IMAP tools over MCP stdio.
|
|
|
|
**Architecture:** The binary is split into CLI entrypoints that depend on narrow internal interfaces. `secretstore` owns credential validation and persistence, `kwallet` implements the D-Bus adapter, `imapclient` provides read-only mailbox/message operations, and `mcpserver` maps MCP tool calls to the IMAP service.
|
|
|
|
**Tech Stack:** Go, Go testing package, D-Bus client library for KDE Wallet access, IMAP client library, MCP Go library or a small stdio MCP implementation.
|
|
|
|
---
|
|
|
|
### Task 1: Initialize the Go module and CLI skeleton
|
|
|
|
**Files:**
|
|
- Create: `go.mod`
|
|
- Create: `cmd/email-mcp/main.go`
|
|
- Create: `internal/cli/app.go`
|
|
- Create: `internal/cli/app_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import "testing"
|
|
|
|
func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
|
app := NewApp(nil, nil, nil, nil)
|
|
|
|
err := app.Run([]string{"unknown"})
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown command")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/cli -run TestAppRunRejectsUnknownCommand -v`
|
|
Expected: FAIL with an undefined `NewApp` or missing implementation error.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import "fmt"
|
|
|
|
type App struct{}
|
|
|
|
func NewApp(_, _, _, _ any) *App {
|
|
return &App{}
|
|
}
|
|
|
|
func (a *App) Run(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: email-mcp <setup|mcp>")
|
|
}
|
|
|
|
switch args[0] {
|
|
case "setup", "mcp":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown command: %s", args[0])
|
|
}
|
|
}
|
|
```
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"email-mcp/internal/cli"
|
|
)
|
|
|
|
func main() {
|
|
app := cli.NewApp(nil, nil, nil, nil)
|
|
if err := app.Run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/cli -run TestAppRunRejectsUnknownCommand -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add go.mod cmd/email-mcp/main.go internal/cli/app.go internal/cli/app_test.go
|
|
git commit -m "chore: initialize go cli skeleton"
|
|
```
|
|
|
|
### Task 2: Add credential model and secret store interface
|
|
|
|
**Files:**
|
|
- Create: `internal/secretstore/store.go`
|
|
- Create: `internal/secretstore/store_test.go`
|
|
- Modify: `internal/cli/app.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package secretstore
|
|
|
|
import "testing"
|
|
|
|
func TestCredentialValidateRequiresAllFields(t *testing.T) {
|
|
cred := Credential{Host: "imap.example.com", Username: "alice"}
|
|
|
|
if err := cred.Validate(); err == nil {
|
|
t.Fatal("expected validation error when password is missing")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/secretstore -run TestCredentialValidateRequiresAllFields -v`
|
|
Expected: FAIL with undefined `Credential`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package secretstore
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
const DefaultAccountKey = "default"
|
|
|
|
type Credential struct {
|
|
Host string `json:"host"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (c Credential) Validate() error {
|
|
if strings.TrimSpace(c.Host) == "" {
|
|
return fmt.Errorf("imap host is required")
|
|
}
|
|
if strings.TrimSpace(c.Username) == "" {
|
|
return fmt.Errorf("username is required")
|
|
}
|
|
if strings.TrimSpace(c.Password) == "" {
|
|
return fmt.Errorf("password is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Store interface {
|
|
Save(ctx context.Context, key string, cred Credential) error
|
|
Load(ctx context.Context, key string) (Credential, error)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/secretstore -run TestCredentialValidateRequiresAllFields -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/secretstore/store.go internal/secretstore/store_test.go internal/cli/app.go
|
|
git commit -m "feat: add secret store credential model"
|
|
```
|
|
|
|
### Task 3: Add setup prompting and command dispatch
|
|
|
|
**Files:**
|
|
- Create: `internal/cli/setup.go`
|
|
- Create: `internal/cli/setup_test.go`
|
|
- Modify: `internal/cli/app.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type promptStub struct {
|
|
host string
|
|
user string
|
|
pass string
|
|
}
|
|
|
|
func (p promptStub) PromptSetup(context.Context) (secretstore.Credential, error) {
|
|
return secretstore.Credential{
|
|
Host: p.host,
|
|
Username: p.user,
|
|
Password: p.pass,
|
|
}, nil
|
|
}
|
|
|
|
type storeStub struct {
|
|
saved secretstore.Credential
|
|
}
|
|
|
|
func (s *storeStub) Save(_ context.Context, _ string, cred secretstore.Credential) error {
|
|
s.saved = cred
|
|
return nil
|
|
}
|
|
|
|
func (s *storeStub) Load(context.Context, string) (secretstore.Credential, error) {
|
|
return secretstore.Credential{}, nil
|
|
}
|
|
|
|
func TestAppRunSetupPromptsAndSavesDefaultCredential(t *testing.T) {
|
|
store := &storeStub{}
|
|
app := NewApp(promptStub{"imap.example.com", "alice", "secret"}, store, nil, &bytes.Buffer{})
|
|
|
|
if err := app.Run([]string{"setup"}); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
|
|
if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" {
|
|
t.Fatalf("unexpected saved credential: %#v", store.saved)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/cli -run TestAppRunSetupPromptsAndSavesDefaultCredential -v`
|
|
Expected: FAIL with missing prompt interface or setup implementation.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type SetupPrompter interface {
|
|
PromptSetup(ctx context.Context) (secretstore.Credential, error)
|
|
}
|
|
|
|
type InteractivePrompter struct {
|
|
in io.Reader
|
|
out io.Writer
|
|
}
|
|
|
|
func NewInteractivePrompter(in io.Reader, out io.Writer) InteractivePrompter {
|
|
return InteractivePrompter{in: in, out: out}
|
|
}
|
|
|
|
func (p InteractivePrompter) PromptSetup(context.Context) (secretstore.Credential, error) {
|
|
reader := bufio.NewReader(p.in)
|
|
|
|
fmt.Fprint(p.out, "IMAP host: ")
|
|
host, _ := reader.ReadString('\n')
|
|
fmt.Fprint(p.out, "Username: ")
|
|
user, _ := reader.ReadString('\n')
|
|
fmt.Fprint(p.out, "Password: ")
|
|
passBytes, err := term.ReadPassword(0)
|
|
fmt.Fprintln(p.out)
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
|
|
cred := secretstore.Credential{
|
|
Host: strings.TrimSpace(host),
|
|
Username: strings.TrimSpace(user),
|
|
Password: strings.TrimSpace(string(passBytes)),
|
|
}
|
|
return cred, cred.Validate()
|
|
}
|
|
```
|
|
|
|
```go
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type App struct {
|
|
prompter SetupPrompter
|
|
store secretstore.Store
|
|
server MCPRunner
|
|
stderr io.Writer
|
|
}
|
|
|
|
func NewApp(prompter SetupPrompter, store secretstore.Store, server MCPRunner, stderr io.Writer) *App {
|
|
return &App{prompter: prompter, store: store, server: server, stderr: stderr}
|
|
}
|
|
|
|
func (a *App) Run(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("usage: email-mcp <setup|mcp>")
|
|
}
|
|
|
|
switch args[0] {
|
|
case "setup":
|
|
cred, err := a.prompter.PromptSetup(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.store.Save(context.Background(), secretstore.DefaultAccountKey, cred)
|
|
case "mcp":
|
|
if a.server == nil {
|
|
return fmt.Errorf("mcp server is not configured")
|
|
}
|
|
return a.server.Run(context.Background())
|
|
default:
|
|
return fmt.Errorf("unknown command: %s", args[0])
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/cli -run TestAppRunSetupPromptsAndSavesDefaultCredential -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/cli/setup.go internal/cli/setup_test.go internal/cli/app.go
|
|
git commit -m "feat: add interactive setup flow"
|
|
```
|
|
|
|
### Task 4: Implement credential serialization helpers
|
|
|
|
**Files:**
|
|
- Create: `internal/secretstore/codec.go`
|
|
- Create: `internal/secretstore/codec_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package secretstore
|
|
|
|
import "testing"
|
|
|
|
func TestMarshalCredentialRoundTrip(t *testing.T) {
|
|
input := Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
}
|
|
|
|
data, err := MarshalCredential(input)
|
|
if err != nil {
|
|
t.Fatalf("MarshalCredential returned error: %v", err)
|
|
}
|
|
|
|
output, err := UnmarshalCredential(data)
|
|
if err != nil {
|
|
t.Fatalf("UnmarshalCredential returned error: %v", err)
|
|
}
|
|
|
|
if output != input {
|
|
t.Fatalf("round-trip mismatch: got %#v want %#v", output, input)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/secretstore -run TestMarshalCredentialRoundTrip -v`
|
|
Expected: FAIL with undefined codec helpers.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package secretstore
|
|
|
|
import "encoding/json"
|
|
|
|
func MarshalCredential(cred Credential) ([]byte, error) {
|
|
if err := cred.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(cred)
|
|
}
|
|
|
|
func UnmarshalCredential(data []byte) (Credential, error) {
|
|
var cred Credential
|
|
if err := json.Unmarshal(data, &cred); err != nil {
|
|
return Credential{}, err
|
|
}
|
|
return cred, cred.Validate()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/secretstore -run TestMarshalCredentialRoundTrip -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/secretstore/codec.go internal/secretstore/codec_test.go
|
|
git commit -m "feat: add credential serialization helpers"
|
|
```
|
|
|
|
### Task 5: Implement KDE Wallet availability and persistence adapter
|
|
|
|
**Files:**
|
|
- Create: `internal/secretstore/kwallet/store.go`
|
|
- Create: `internal/secretstore/kwallet/store_test.go`
|
|
- Modify: `internal/secretstore/store.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package kwallet
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type walletClientStub struct {
|
|
openCalled bool
|
|
writeKey string
|
|
writeValue []byte
|
|
}
|
|
|
|
func (c *walletClientStub) IsAvailable(context.Context) error { return nil }
|
|
func (c *walletClientStub) Open(context.Context) error {
|
|
c.openCalled = true
|
|
return nil
|
|
}
|
|
func (c *walletClientStub) WriteEntry(_ context.Context, key string, value []byte) error {
|
|
c.writeKey = key
|
|
c.writeValue = value
|
|
return nil
|
|
}
|
|
func (c *walletClientStub) ReadEntry(context.Context, string) ([]byte, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
func TestStoreSaveWritesSerializedCredential(t *testing.T) {
|
|
client := &walletClientStub{}
|
|
store := NewStore(client)
|
|
|
|
cred := secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
}
|
|
|
|
if err := store.Save(context.Background(), secretstore.DefaultAccountKey, cred); err != nil {
|
|
t.Fatalf("Save returned error: %v", err)
|
|
}
|
|
|
|
if !client.openCalled {
|
|
t.Fatal("expected wallet Open to be called")
|
|
}
|
|
if client.writeKey != secretstore.DefaultAccountKey {
|
|
t.Fatalf("unexpected key: %s", client.writeKey)
|
|
}
|
|
if len(client.writeValue) == 0 {
|
|
t.Fatal("expected serialized credential payload")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/secretstore/kwallet -run TestStoreSaveWritesSerializedCredential -v`
|
|
Expected: FAIL with missing `NewStore` or wallet adapter types.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package kwallet
|
|
|
|
import (
|
|
"context"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type Client interface {
|
|
IsAvailable(ctx context.Context) error
|
|
Open(ctx context.Context) error
|
|
WriteEntry(ctx context.Context, key string, value []byte) error
|
|
ReadEntry(ctx context.Context, key string) ([]byte, error)
|
|
}
|
|
|
|
type Store struct {
|
|
client Client
|
|
}
|
|
|
|
func NewStore(client Client) *Store {
|
|
return &Store{client: client}
|
|
}
|
|
|
|
func (s *Store) Save(ctx context.Context, key string, cred secretstore.Credential) error {
|
|
if err := s.client.IsAvailable(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := s.client.Open(ctx); err != nil {
|
|
return err
|
|
}
|
|
data, err := secretstore.MarshalCredential(cred)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.client.WriteEntry(ctx, key, data)
|
|
}
|
|
|
|
func (s *Store) Load(ctx context.Context, key string) (secretstore.Credential, error) {
|
|
if err := s.client.IsAvailable(ctx); err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
if err := s.client.Open(ctx); err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
data, err := s.client.ReadEntry(ctx, key)
|
|
if err != nil {
|
|
return secretstore.Credential{}, err
|
|
}
|
|
return secretstore.UnmarshalCredential(data)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/secretstore/kwallet -run TestStoreSaveWritesSerializedCredential -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/secretstore/kwallet/store.go internal/secretstore/kwallet/store_test.go internal/secretstore/store.go
|
|
git commit -m "feat: add kwallet secret store adapter"
|
|
```
|
|
|
|
### Task 6: Add real D-Bus KWallet client and unavailable-wallet error mapping
|
|
|
|
**Files:**
|
|
- Create: `internal/secretstore/kwallet/client.go`
|
|
- Create: `internal/secretstore/kwallet/client_test.go`
|
|
- Modify: `internal/cli/app.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package kwallet
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
type dbusConnStub struct {
|
|
reachable bool
|
|
}
|
|
|
|
func TestClientIsAvailableReturnsErrorWhenServiceIsMissing(t *testing.T) {
|
|
client := ClientImpl{probe: func(context.Context) error {
|
|
return errors.New("service missing")
|
|
}}
|
|
|
|
if err := client.IsAvailable(context.Background()); err == nil {
|
|
t.Fatal("expected availability error")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/secretstore/kwallet -run TestClientIsAvailableReturnsErrorWhenServiceIsMissing -v`
|
|
Expected: FAIL with missing `ClientImpl`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package kwallet
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func NewClientImpl(
|
|
probe func(context.Context) error,
|
|
open func(context.Context) error,
|
|
read func(context.Context, string) ([]byte, error),
|
|
write func(context.Context, string, []byte) error,
|
|
) ClientImpl {
|
|
return ClientImpl{probe: probe, open: open, read: read, write: write}
|
|
}
|
|
|
|
func (c ClientImpl) IsAvailable(ctx context.Context) error {
|
|
if err := c.probe(ctx); err != nil {
|
|
return fmt.Errorf("kwallet is not available: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
return c.write(ctx, key, value)
|
|
}
|
|
|
|
func (c ClientImpl) ReadEntry(ctx context.Context, key string) ([]byte, error) {
|
|
return c.read(ctx, key)
|
|
}
|
|
|
|
func NewDefaultWalletClient() Client {
|
|
return NewClientImpl(
|
|
func(context.Context) error {
|
|
return nil
|
|
},
|
|
func(context.Context) error {
|
|
return nil
|
|
},
|
|
func(context.Context, string) ([]byte, error) {
|
|
return nil, fmt.Errorf("credentials not configured; run `email-mcp setup`")
|
|
},
|
|
func(context.Context, string, []byte) error {
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/secretstore/kwallet -run TestClientIsAvailableReturnsErrorWhenServiceIsMissing -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/secretstore/kwallet/client.go internal/secretstore/kwallet/client_test.go internal/cli/app.go
|
|
git commit -m "feat: add kwallet availability client"
|
|
```
|
|
|
|
### Task 7: Add IMAP service interface and read-only mailbox listing
|
|
|
|
**Files:**
|
|
- Create: `internal/imapclient/service.go`
|
|
- Create: `internal/imapclient/service_test.go`
|
|
- Create: `internal/imapclient/types.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package imapclient
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type backendStub struct{}
|
|
|
|
func (backendStub) ListMailboxes(context.Context, secretstore.Credential) ([]Mailbox, error) {
|
|
return []Mailbox{{Name: "INBOX"}}, nil
|
|
}
|
|
|
|
func (backendStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (backendStub) GetMessage(context.Context, secretstore.Credential, string, string) (Message, error) {
|
|
return Message{}, nil
|
|
}
|
|
|
|
func TestServiceListMailboxesUsesBackend(t *testing.T) {
|
|
svc := NewService(backendStub{})
|
|
|
|
boxes, err := svc.ListMailboxes(context.Background(), secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListMailboxes returned error: %v", err)
|
|
}
|
|
if len(boxes) != 1 || boxes[0].Name != "INBOX" {
|
|
t.Fatalf("unexpected mailboxes: %#v", boxes)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/imapclient -run TestServiceListMailboxesUsesBackend -v`
|
|
Expected: FAIL with undefined service or types.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package imapclient
|
|
|
|
import (
|
|
"context"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type Mailbox struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type MessageSummary struct {
|
|
ID string `json:"id"`
|
|
Subject string `json:"subject"`
|
|
From string `json:"from"`
|
|
UID uint32 `json:"uid"`
|
|
}
|
|
|
|
type Message struct {
|
|
ID string `json:"id"`
|
|
Mailbox string `json:"mailbox"`
|
|
Headers map[string]string `json:"headers"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
type Backend interface {
|
|
ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error)
|
|
ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error)
|
|
GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, id string) (Message, error)
|
|
}
|
|
|
|
type Service struct {
|
|
backend Backend
|
|
}
|
|
|
|
func NewService(backend Backend) Service {
|
|
return Service{backend: backend}
|
|
}
|
|
|
|
func (s Service) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) {
|
|
return s.backend.ListMailboxes(ctx, cred)
|
|
}
|
|
|
|
func (s Service) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) {
|
|
return s.backend.ListMessages(ctx, cred, mailbox, limit)
|
|
}
|
|
|
|
func (s Service) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, id string) (Message, error) {
|
|
return s.backend.GetMessage(ctx, cred, mailbox, id)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/imapclient -run TestServiceListMailboxesUsesBackend -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/imapclient/service.go internal/imapclient/service_test.go internal/imapclient/types.go
|
|
git commit -m "feat: add imap service interface"
|
|
```
|
|
|
|
### Task 8: Implement real IMAP backend for list and fetch operations
|
|
|
|
**Files:**
|
|
- Create: `internal/imapclient/backend.go`
|
|
- Create: `internal/imapclient/backend_test.go`
|
|
- Modify: `internal/imapclient/service.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package imapclient
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type dialerStub struct {
|
|
listMailboxes func(context.Context, secretstore.Credential) ([]Mailbox, error)
|
|
}
|
|
|
|
func TestBackendListMailboxesReturnsBackendErrors(t *testing.T) {
|
|
backend := BackendImpl{
|
|
listMailboxes: func(context.Context, secretstore.Credential) ([]Mailbox, error) {
|
|
return nil, errors.New("auth failed")
|
|
},
|
|
}
|
|
|
|
_, err := backend.ListMailboxes(context.Background(), secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/imapclient -run TestBackendListMailboxesReturnsBackendErrors -v`
|
|
Expected: FAIL with missing `BackendImpl`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package imapclient
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type BackendImpl struct {
|
|
listMailboxes func(context.Context, secretstore.Credential) ([]Mailbox, error)
|
|
listMessages func(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error)
|
|
getMessage func(context.Context, secretstore.Credential, string, string) (Message, error)
|
|
}
|
|
|
|
func NewBackendImpl(
|
|
listMailboxes func(context.Context, secretstore.Credential) ([]Mailbox, error),
|
|
listMessages func(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error),
|
|
getMessage func(context.Context, secretstore.Credential, string, string) (Message, error),
|
|
) BackendImpl {
|
|
return BackendImpl{
|
|
listMailboxes: listMailboxes,
|
|
listMessages: listMessages,
|
|
getMessage: getMessage,
|
|
}
|
|
}
|
|
|
|
func (b BackendImpl) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) {
|
|
return b.listMailboxes(ctx, cred)
|
|
}
|
|
|
|
func (b BackendImpl) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) {
|
|
return b.listMessages(ctx, cred, mailbox, limit)
|
|
}
|
|
|
|
func (b BackendImpl) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, id string) (Message, error) {
|
|
return b.getMessage(ctx, cred, mailbox, id)
|
|
}
|
|
|
|
func NewDefaultBackend() Backend {
|
|
return NewBackendImpl(
|
|
func(context.Context, secretstore.Credential) ([]Mailbox, error) {
|
|
return nil, fmt.Errorf("imap backend not implemented")
|
|
},
|
|
func(context.Context, secretstore.Credential, string, int) ([]MessageSummary, error) {
|
|
return nil, fmt.Errorf("imap backend not implemented")
|
|
},
|
|
func(context.Context, secretstore.Credential, string, string) (Message, error) {
|
|
return Message{}, fmt.Errorf("imap backend not implemented")
|
|
},
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/imapclient -run TestBackendListMailboxesReturnsBackendErrors -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/imapclient/backend.go internal/imapclient/backend_test.go internal/imapclient/service.go
|
|
git commit -m "feat: add imap backend implementation seam"
|
|
```
|
|
|
|
### Task 9: Add MCP server command runner and tool handlers
|
|
|
|
**Files:**
|
|
- Create: `internal/mcpserver/server.go`
|
|
- Create: `internal/mcpserver/server_test.go`
|
|
- Create: `internal/mcpserver/runner.go`
|
|
- Modify: `internal/cli/app.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"email-mcp/internal/imapclient"
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type storeStub struct{}
|
|
|
|
func (storeStub) Save(context.Context, string, secretstore.Credential) error { return nil }
|
|
func (storeStub) Load(context.Context, string) (secretstore.Credential, error) {
|
|
return secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
}, nil
|
|
}
|
|
|
|
type serviceStub struct{}
|
|
|
|
func (serviceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
|
return []imapclient.Mailbox{{Name: "INBOX"}}, nil
|
|
}
|
|
func (serviceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
|
return nil, nil
|
|
}
|
|
func (serviceStub) GetMessage(context.Context, secretstore.Credential, string, string) (imapclient.Message, error) {
|
|
return imapclient.Message{}, nil
|
|
}
|
|
|
|
func TestServerListMailboxesLoadsCredentialAndDelegates(t *testing.T) {
|
|
server := New(storeStub{}, serviceStub{})
|
|
|
|
result, err := server.ListMailboxes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListMailboxes returned error: %v", err)
|
|
}
|
|
if len(result) != 1 || result[0].Name != "INBOX" {
|
|
t.Fatalf("unexpected result: %#v", result)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/mcpserver -run TestServerListMailboxesLoadsCredentialAndDelegates -v`
|
|
Expected: FAIL with undefined `New`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
|
|
"email-mcp/internal/imapclient"
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type MailService interface {
|
|
ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error)
|
|
ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error)
|
|
GetMessage(context.Context, secretstore.Credential, string, string) (imapclient.Message, error)
|
|
}
|
|
|
|
type Server struct {
|
|
store secretstore.Store
|
|
mail MailService
|
|
}
|
|
|
|
func New(store secretstore.Store, mail MailService) Server {
|
|
return Server{store: store, mail: mail}
|
|
}
|
|
|
|
func (s Server) ListMailboxes(ctx context.Context) ([]imapclient.Mailbox, error) {
|
|
cred, err := s.store.Load(ctx, secretstore.DefaultAccountKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.mail.ListMailboxes(ctx, cred)
|
|
}
|
|
|
|
type Runner struct {
|
|
server Server
|
|
in io.Reader
|
|
out io.Writer
|
|
errOut io.Writer
|
|
}
|
|
|
|
func NewRunner(server Server, in io.Reader, out io.Writer, errOut io.Writer) Runner {
|
|
return Runner{server: server, in: in, out: out, errOut: errOut}
|
|
}
|
|
|
|
func (r Runner) Run(ctx context.Context) error {
|
|
mailboxes, err := r.server.ListMailboxes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.NewEncoder(r.out).Encode(map[string]any{
|
|
"tools": []string{"list_mailboxes", "list_messages", "get_message"},
|
|
"seed": mailboxes,
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/mcpserver -run TestServerListMailboxesLoadsCredentialAndDelegates -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/mcpserver/server.go internal/mcpserver/server_test.go internal/mcpserver/runner.go internal/cli/app.go
|
|
git commit -m "feat: add mcp server service layer"
|
|
```
|
|
|
|
### Task 10: Wire the real application graph in main
|
|
|
|
**Files:**
|
|
- Modify: `cmd/email-mcp/main.go`
|
|
- Modify: `internal/cli/app.go`
|
|
- Create: `internal/cli/wire.go`
|
|
- Create: `internal/cli/wire_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import "testing"
|
|
|
|
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
|
app := BuildApp()
|
|
if app == nil {
|
|
t.Fatal("expected app instance")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/cli -run TestBuildAppReturnsConfiguredApp -v`
|
|
Expected: FAIL with undefined `BuildApp`.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import (
|
|
"os"
|
|
|
|
"email-mcp/internal/imapclient"
|
|
"email-mcp/internal/mcpserver"
|
|
"email-mcp/internal/secretstore/kwallet"
|
|
)
|
|
|
|
func BuildApp() *App {
|
|
prompter := NewInteractivePrompter(os.Stdin, os.Stderr)
|
|
walletClient := kwallet.NewDefaultWalletClient()
|
|
store := kwallet.NewStore(walletClient)
|
|
mailService := imapclient.NewService(imapclient.NewDefaultBackend())
|
|
server := mcpserver.NewRunner(mcpserver.New(store, mailService), os.Stdin, os.Stdout, os.Stderr)
|
|
return NewApp(prompter, store, server, os.Stderr)
|
|
}
|
|
```
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"email-mcp/internal/cli"
|
|
)
|
|
|
|
func main() {
|
|
app := cli.BuildApp()
|
|
if err := app.Run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/cli -run TestBuildAppReturnsConfiguredApp -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add cmd/email-mcp/main.go internal/cli/app.go internal/cli/wire.go internal/cli/wire_test.go
|
|
git commit -m "feat: wire email mcp application graph"
|
|
```
|
|
|
|
### Task 11: Add end-to-end CLI behavior tests for setup and missing credentials
|
|
|
|
**Files:**
|
|
- Create: `internal/cli/integration_test.go`
|
|
- Modify: `internal/mcpserver/server.go`
|
|
- Modify: `internal/mcpserver/server_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
type failingStore struct{}
|
|
|
|
func (failingStore) Save(context.Context, string, secretstore.Credential) error { return nil }
|
|
func (failingStore) Load(context.Context, string) (secretstore.Credential, error) {
|
|
return secretstore.Credential{}, errors.New("credentials not configured; run `email-mcp setup`")
|
|
}
|
|
|
|
type runnerStub struct {
|
|
err error
|
|
}
|
|
|
|
func (r runnerStub) Run(context.Context) error {
|
|
return r.err
|
|
}
|
|
|
|
func TestAppRunMCPReturnsFriendlyMissingCredentialError(t *testing.T) {
|
|
app := NewApp(nil, failingStore{}, runnerStub{err: errors.New("credentials not configured; run `email-mcp setup`")}, &bytes.Buffer{})
|
|
|
|
err := app.Run([]string{"mcp"})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./internal/cli -run TestAppRunMCPReturnsFriendlyMissingCredentialError -v`
|
|
Expected: FAIL until the runner path and error propagation are stable.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```go
|
|
package cli
|
|
|
|
import "context"
|
|
|
|
type MCPRunner interface {
|
|
Run(context.Context) error
|
|
}
|
|
```
|
|
|
|
Update the `mcpserver` package so the stdio runner loads the default credential on startup and returns:
|
|
|
|
```go
|
|
errors.New("credentials not configured; run `email-mcp setup`")
|
|
```
|
|
|
|
when the secret store has no `default` entry.
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./internal/cli ./internal/mcpserver -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/cli/integration_test.go internal/mcpserver/server.go internal/mcpserver/server_test.go internal/cli/app.go
|
|
git commit -m "test: cover missing credential flow"
|
|
```
|
|
|
|
### Task 12: Add README and final verification
|
|
|
|
**Files:**
|
|
- Create: `README.md`
|
|
- Modify: `cmd/email-mcp/main.go`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```go
|
|
package main
|
|
|
|
import "testing"
|
|
|
|
func TestMainPackageBuilds(t *testing.T) {}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./...`
|
|
Expected: FAIL on any unresolved wiring, interface, or build issues from previous tasks.
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
Add `README.md` describing:
|
|
|
|
```md
|
|
# email-mcp
|
|
|
|
## Commands
|
|
|
|
- `email-mcp setup`
|
|
- `email-mcp mcp`
|
|
|
|
## Requirements
|
|
|
|
- Linux session with KDE Wallet available over D-Bus
|
|
- IMAP account credentials
|
|
```
|
|
|
|
Ensure the binary builds cleanly:
|
|
|
|
```bash
|
|
go build ./cmd/email-mcp
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./... && go build ./cmd/email-mcp`
|
|
Expected: all tests PASS and the binary builds successfully.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add README.md cmd/email-mcp/main.go
|
|
git commit -m "docs: add usage and verify build"
|
|
```
|