email-mcp/internal/cli/app.go
2026-04-14 09:04:40 +02:00

628 lines
16 KiB
Go

package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap"
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
"email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore"
)
const (
binaryName = "email-mcp"
defaultProfileEnv = "EMAIL_MCP_PROFILE"
hostEnv = "EMAIL_MCP_HOST"
usernameEnv = "EMAIL_MCP_USERNAME"
passwordEnv = "EMAIL_MCP_PASSWORD"
binaryDescription = "Local MCP server to read an IMAP mailbox."
)
type MCPRunner interface {
Run(ctx context.Context) error
}
type ConfigPrompter interface {
PromptCredential(ctx context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error)
}
type profileConfigStore interface {
LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error)
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
}
type secretStore interface {
SetSecret(name, label, secret string) error
GetSecret(name string) (string, error)
DeleteSecret(name string) error
}
type manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
type executableResolver func() (string, error)
type ProfileConfig struct {
Host string `json:"host"`
Username string `json:"username"`
}
type App struct {
prompter ConfigPrompter
configStore profileConfigStore
openSecretStore func() (secretStore, error)
newMailService func() mcpserver.MailService
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
loadManifest manifestLoader
resolveExecutable executableResolver
stdin io.Reader
stdout io.Writer
stderr io.Writer
version string
}
func NewApp() *App {
return BuildApp("dev")
}
func NewAppWithDependencies(
prompter ConfigPrompter,
configStore profileConfigStore,
openSecretStore func() (secretStore, error),
newMailService func() mcpserver.MailService,
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner,
loadManifest manifestLoader,
resolveExecutable executableResolver,
stdin io.Reader,
stdout io.Writer,
stderr io.Writer,
version string,
) *App {
if stdin == nil {
stdin = strings.NewReader("")
}
if stdout == nil {
stdout = io.Discard
}
if stderr == nil {
stderr = io.Discard
}
if version == "" {
version = "dev"
}
return &App{
prompter: prompter,
configStore: configStore,
openSecretStore: openSecretStore,
newMailService: newMailService,
newRunner: newRunner,
loadManifest: loadManifest,
resolveExecutable: resolveExecutable,
stdin: stdin,
stdout: stdout,
stderr: stderr,
version: version,
}
}
func (a *App) Run(args []string) error {
if isDoctorHelpCommand(args) {
return a.printDoctorHelp()
}
if len(args) > 0 && strings.TrimSpace(args[0]) == "doctor" {
return a.runDoctor(context.Background(), args[1:])
}
if isGlobalHelpCommand(args) {
return a.printGlobalHelp()
}
return a.runBootstrap(context.Background(), args)
}
func (a *App) runBootstrap(ctx context.Context, args []string) error {
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
BinaryName: binaryName,
Description: binaryDescription,
Version: a.version,
Args: args,
Stdin: a.stdin,
Stdout: a.stdout,
Stderr: a.stderr,
Hooks: frameworkbootstrap.Hooks{
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
},
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runMCP(ctx, inv.Args)
},
Config: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runConfig(ctx, frameworkbootstrap.CommandConfig, inv.Args)
},
Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runUpdate(ctx, inv.Args)
},
},
})
}
func isGlobalHelpCommand(args []string) bool {
if len(args) == 0 {
return true
}
if len(args) != 1 {
return false
}
switch strings.TrimSpace(args[0]) {
case "help", "-h", "--help":
return true
default:
return false
}
}
func isDoctorHelpCommand(args []string) bool {
if len(args) != 2 {
return false
}
first := strings.TrimSpace(args[0])
second := strings.TrimSpace(args[1])
if first == "help" && second == "doctor" {
return true
}
if first == "doctor" && (second == "-h" || second == "--help") {
return true
}
return false
}
func (a *App) printGlobalHelp() error {
if _, err := fmt.Fprintf(a.stdout, "%s\n\n", binaryDescription); err != nil {
return err
}
if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s <command> [args]\n\n", binaryName); err != nil {
return err
}
if _, err := fmt.Fprintln(a.stdout, "Common commands:"); err != nil {
return err
}
commands := []struct {
name string
description string
}{
{name: "setup", description: "Initialize or update local configuration."},
{name: "mcp", description: "Run the MCP server over stdio."},
{name: "config", description: "Inspect or update configuration."},
{name: "doctor", description: "Run local diagnostics."},
{name: "update", description: "Run the self-update flow."},
{name: "version", description: "Print the binary version."},
}
for _, command := range commands {
if _, err := fmt.Fprintf(a.stdout, " %-7s %s\n", command.name, command.description); err != nil {
return err
}
}
_, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help <command>\n", binaryName)
return err
}
func (a *App) printDoctorHelp() error {
_, err := fmt.Fprintf(
a.stdout,
"Usage:\n %s doctor [--profile NAME]\n\nRun local diagnostics for config, wallet, manifest, and IMAP connectivity.\n",
binaryName,
)
return err
}
func (a *App) runConfig(ctx context.Context, command string, args []string) error {
if a.prompter == nil {
return fmt.Errorf("config prompter is not configured")
}
if a.configStore == nil {
return fmt.Errorf("config store is not configured")
}
if a.openSecretStore == nil {
return fmt.Errorf("secret store is not configured")
}
profileFlag, err := parseProfileArgs(command, args)
if err != nil {
return err
}
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
return err
}
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore()
if err != nil {
return mapAppError(err)
}
storedPassword, hasStoredPassword, err := loadStoredPassword(secrets, profileName)
if err != nil {
return mapAppError(err)
}
cred, err := a.prompter.PromptCredential(ctx, secretstore.Credential{
Host: profile.Host,
Username: profile.Username,
Password: storedPassword,
}, hasStoredPassword)
if err != nil {
return err
}
if err := cred.Validate(); err != nil {
return err
}
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
return mapAppError(err)
}
if cfg.Profiles == nil {
cfg.Profiles = map[string]ProfileConfig{}
}
cfg.CurrentProfile = profileName
cfg.Profiles[profileName] = ProfileConfig{
Host: cred.Host,
Username: cred.Username,
}
configPath, err := a.configStore.SaveDefault(cfg)
if err != nil {
return err
}
fmt.Fprintf(a.stdout, "profile %q saved to %s\n", profileName, configPath)
return nil
}
func (a *App) runMCP(ctx context.Context, args []string) error {
if a.newRunner == nil {
return fmt.Errorf("mcp runner is not configured")
}
if a.newMailService == nil {
return fmt.Errorf("mail service is not configured")
}
profileFlag, err := parseProfileArgs("mcp", args)
if err != nil {
return err
}
cred, err := a.loadCredential(profileFlag)
if err != nil {
return mapAppError(err)
}
runner := a.newRunner(cred, a.newMailService(), a.stdin, a.stdout, a.stderr)
if runner == nil {
return fmt.Errorf("mcp runner is not configured")
}
return mapAppError(runner.Run(ctx))
}
func (a *App) runUpdate(ctx context.Context, args []string) error {
if a.loadManifest == nil {
return fmt.Errorf("manifest loader is not configured")
}
if a.resolveExecutable == nil {
return fmt.Errorf("executable resolver is not configured")
}
if err := parseUpdateArgs(args); err != nil {
return err
}
executablePath, err := a.resolveExecutable()
if err != nil {
return fmt.Errorf("resolve executable path: %w", err)
}
manifestFile, err := a.loadManifestForExecutable(executablePath)
if err != nil {
return err
}
return frameworkupdate.Run(ctx, frameworkupdate.Options{
CurrentVersion: a.version,
ExecutablePath: executablePath,
BinaryName: binaryName,
ReleaseSource: manifestFile.Update.ReleaseSource(),
Stdout: a.stdout,
})
}
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
searchDirs := []string{filepath.Dir(executablePath), "."}
var firstErr error
for _, dir := range searchDirs {
file, _, err := a.loadManifest(dir)
if err == nil {
return file, nil
}
if firstErr == nil {
firstErr = err
}
}
return frameworkmanifest.File{}, fmt.Errorf("load manifest: %w", firstErr)
}
func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) {
if a.configStore == nil {
return secretstore.Credential{}, fmt.Errorf("config store is not configured")
}
if a.openSecretStore == nil {
return secretstore.Credential{}, fmt.Errorf("secret store is not configured")
}
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
return secretstore.Credential{}, err
}
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore()
if err != nil {
return secretstore.Credential{}, err
}
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) {
return secretstore.Credential{}, fmt.Errorf(
"%w: profile %q is incomplete (missing: %s)",
mcpserver.ErrCredentialsNotConfigured,
profileName,
strings.Join(missingErr.Fields, ", "),
)
}
return secretstore.Credential{}, err
}
cred, err := credentialFromResolution(resolution)
if err != nil {
return secretstore.Credential{}, err
}
if err := cred.Validate(); err != nil {
return secretstore.Credential{}, fmt.Errorf("%w: profile %q is incomplete", mcpserver.ErrCredentialsNotConfigured, profileName)
}
return cred, nil
}
func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec {
return []frameworkcli.FieldSpec{
{
Name: "host",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: hostEnv,
ConfigKey: "host",
},
{
Name: "username",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: usernameEnv,
ConfigKey: "username",
},
passwordFieldSpec(profileName),
}
}
func profileFieldSpecs() []frameworkcli.FieldSpec {
return []frameworkcli.FieldSpec{
{
Name: "host",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: hostEnv,
ConfigKey: "host",
},
{
Name: "username",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: usernameEnv,
ConfigKey: "username",
},
}
}
func passwordFieldSpec(profileName string) frameworkcli.FieldSpec {
return frameworkcli.FieldSpec{
Name: "password",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceSecret,
},
EnvKey: passwordEnv,
SecretKey: passwordSecretName(profileName),
}
}
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) {
configValues := map[string]string{
"host": profile.Host,
"username": profile.Username,
}
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
Fields: fields,
Lookup: func(source frameworkcli.ValueSource, key string) (string, bool, error) {
switch source {
case frameworkcli.SourceEnv:
value, ok := os.LookupEnv(strings.TrimSpace(key))
return value, ok, nil
case frameworkcli.SourceConfig:
value, ok := configValues[strings.TrimSpace(key)]
return value, ok, nil
case frameworkcli.SourceSecret:
if store == nil {
return "", false, nil
}
value, err := store.GetSecret(strings.TrimSpace(key))
if err != nil {
if errors.Is(err, frameworksecretstore.ErrNotFound) {
return "", false, nil
}
return "", false, err
}
return value, true, nil
default:
return "", false, nil
}
},
})
}
func credentialFromResolution(resolution frameworkcli.Resolution) (secretstore.Credential, error) {
host, ok := resolution.Get("host")
if !ok {
return secretstore.Credential{}, fmt.Errorf("resolve credential: host field is missing from resolution")
}
username, ok := resolution.Get("username")
if !ok {
return secretstore.Credential{}, fmt.Errorf("resolve credential: username field is missing from resolution")
}
password, ok := resolution.Get("password")
if !ok {
return secretstore.Credential{}, fmt.Errorf("resolve credential: password field is missing from resolution")
}
return secretstore.Credential{
Host: host.Value,
Username: username.Value,
Password: password.Value,
}, nil
}
func loadStoredPassword(store secretStore, profileName string) (string, bool, error) {
password, err := store.GetSecret(passwordSecretName(profileName))
if err != nil {
if errors.Is(err, frameworksecretstore.ErrNotFound) {
return "", false, nil
}
return "", false, err
}
return password, true, nil
}
func passwordSecretName(profileName string) string {
return "imap-password/" + strings.TrimSpace(profileName)
}
func parseProfileArgs(command string, args []string) (string, error) {
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
flagSet.SetOutput(io.Discard)
profile := flagSet.String("profile", "", "")
if err := flagSet.Parse(args); err != nil {
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
}
if flagSet.NArg() != 0 {
return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command)
}
return strings.TrimSpace(*profile), nil
}
func parseUpdateArgs(args []string) error {
flagSet := flag.NewFlagSet("update", flag.ContinueOnError)
flagSet.SetOutput(io.Discard)
if err := flagSet.Parse(args); err != nil {
return fmt.Errorf("usage: email-mcp update")
}
if flagSet.NArg() != 0 {
return fmt.Errorf("usage: email-mcp update")
}
return nil
}
func mapAppError(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
return newUserFacingError("credentials not configured; run `email-mcp config`", err)
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
return newUserFacingError(
fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
err,
)
case errors.Is(err, frameworksecretstore.ErrReadOnly):
return newUserFacingError("secret backend is read-only", err)
default:
return err
}
}
type userFacingError struct {
message string
err error
}
func (e *userFacingError) Error() string {
return e.message
}
func (e *userFacingError) Unwrap() error {
return e.err
}
func newUserFacingError(message string, err error) error {
return &userFacingError{
message: message,
err: err,
}
}