package cli import ( "context" "errors" "flag" "fmt" "io" "os" "path/filepath" "sort" "strings" "email-mcp/mcpgen" frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap" frameworkcli "forge.lclr.dev/AI/mcp-framework/cli" frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" frameworkupdate "forge.lclr.dev/AI/mcp-framework/update" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" ) const ( binaryName = mcpgen.BinaryName defaultProfileEnv = "EMAIL_MCP_PROFILE" hostEnv = "EMAIL_MCP_HOST" usernameEnv = "EMAIL_MCP_USERNAME" passwordEnv = "EMAIL_MCP_PASSWORD" fallbackProfile = "default" ) 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 = frameworksecretstore.Store 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 args == nil { args = []string{} } return a.runBootstrap(context.Background(), args) } func (a *App) runBootstrap(ctx context.Context, args []string) error { metadata := a.runtimeMetadata() return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{ BinaryName: metadata.BinaryName, Description: metadata.Description, Version: a.version, EnableDoctorAlias: true, 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) }, ConfigShow: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runConfigShow(ctx, inv.Args) }, ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runDoctor(ctx, inv.Args) }, ConfigDelete: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runConfigDelete(ctx, inv.Args) }, Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { return a.runUpdate(ctx, inv.Args) }, }, }) } 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 := a.resolveProfileName(profileFlag, 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 shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) { if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil { switch { case errors.Is(err, frameworksecretstore.ErrReadOnly): if strings.TrimSpace(os.Getenv(passwordEnv)) == "" { return newUserFacingError( fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv), err, ) } if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil { return writeErr } default: 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) runConfigShow(ctx context.Context, args []string) error { 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("config show", args) if err != nil { return err } cfg, _, err := a.configStore.LoadDefault() if err != nil { return err } profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profile := cfg.Profiles[profileName] secrets, err := a.openSecretStore() if err != nil { return mapAppError(err) } resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName)) if err != nil { var missingErr *frameworkcli.MissingRequiredValuesError if !errors.As(err, &missingErr) { return mapAppError(err) } } host, _ := resolution.Get("host") username, _ := resolution.Get("username") password, _ := resolution.Get("password") if _, err := fmt.Fprintf(a.stdout, "profile: %s\n", profileName); err != nil { return err } if _, err := fmt.Fprintf(a.stdout, "host: %s (%s)\n", renderVisibleField(host), renderSource(host)); err != nil { return err } if _, err := fmt.Fprintf(a.stdout, "username: %s (%s)\n", renderVisibleField(username), renderSource(username)); err != nil { return err } if _, err := fmt.Fprintf(a.stdout, "password: %s (%s)\n", renderSecretField(password), renderSource(password)); err != nil { return err } return nil } func (a *App) runConfigDelete(_ context.Context, args []string) error { 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("config delete", args) if err != nil { return err } cfg, _, err := a.configStore.LoadDefault() if err != nil { return err } profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) secrets, err := a.openSecretStore() if err != nil { return mapAppError(err) } if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil { switch { case errors.Is(err, frameworksecretstore.ErrNotFound): case errors.Is(err, frameworksecretstore.ErrReadOnly): if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil { return writeErr } default: return mapAppError(err) } } if cfg.Profiles != nil { delete(cfg.Profiles, profileName) } if strings.TrimSpace(cfg.CurrentProfile) == profileName { cfg.CurrentProfile = nextCurrentProfile(cfg.Profiles, a.runtimeMetadata().DefaultProfile) } configPath, err := a.configStore.SaveDefault(cfg) if err != nil { return err } if _, err := fmt.Fprintf(a.stdout, "profile %q deleted from %s\n", profileName, configPath); err != nil { return err } if cfg.CurrentProfile != "" { if _, err := fmt.Fprintf(a.stdout, "current profile: %s\n", cfg.CurrentProfile); err != nil { return err } } 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) } options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout) if err != nil { return err } options.ExecutablePath = executablePath return frameworkupdate.Run(ctx, options) } 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 := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profile := cfg.Profiles[profileName] secrets, err := a.openSecretStore() if err != nil { return secretstore.Credential{}, err } resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(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 profileFieldSpecs(profileName string) []frameworkcli.FieldSpec { specs := mcpgen.ResolveFieldSpecs(profileName) profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs)) for _, spec := range specs { if spec.Name == "host" || spec.Name == "username" { profileSpecs = append(profileSpecs, spec) } } return profileSpecs } func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec { for _, spec := range mcpgen.ResolveFieldSpecs(profileName) { if spec.Name == "password" { return []frameworkcli.FieldSpec{spec} } } return nil } 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: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{ Env: frameworkcli.EnvLookup(os.LookupEnv), Config: frameworkcli.ConfigMap(configValues), Secret: frameworkcli.SecretStore(store), }), }) } 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 { for _, spec := range mcpgen.ResolveFieldSpecs(profileName) { if spec.Name == "password" && strings.TrimSpace(spec.SecretKey) != "" { return spec.SecretKey } } return "imap-password/" + strings.TrimSpace(profileName) } func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool { if !hasStoredPassword { return true } return storedPassword != newPassword } 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 } type runtimeMetadata struct { BinaryName string Description string DefaultProfile string } func (a *App) runtimeMetadata() runtimeMetadata { metadata := runtimeMetadata{ BinaryName: mcpgen.BinaryName, Description: mcpgen.DefaultDescription, DefaultProfile: fallbackProfile, } if a.loadManifest == nil { return metadata } file, err := a.loadRuntimeManifest() if err != nil { return metadata } bootstrap := file.BootstrapInfo() if bootstrap.BinaryName != "" { metadata.BinaryName = bootstrap.BinaryName } if bootstrap.Description != "" { metadata.Description = bootstrap.Description } if bootstrap.DefaultProfile != "" { metadata.DefaultProfile = bootstrap.DefaultProfile } return metadata } func (a *App) loadRuntimeManifest() (frameworkmanifest.File, error) { if a.loadManifest == nil { return frameworkmanifest.File{}, fmt.Errorf("manifest loader is not configured") } if a.resolveExecutable != nil { executablePath, err := a.resolveExecutable() if err == nil { file, loadErr := a.loadManifestForExecutable(executablePath) if loadErr == nil { return file, nil } } } file, _, err := a.loadManifest(".") if err != nil { return frameworkmanifest.File{}, err } return file, nil } func (a *App) resolveProfileName(profileFlag, currentProfile string) string { resolvedCurrent := strings.TrimSpace(currentProfile) if resolvedCurrent == "" { resolvedCurrent = strings.TrimSpace(a.runtimeMetadata().DefaultProfile) } return frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), resolvedCurrent) } func nextCurrentProfile(profiles map[string]ProfileConfig, preferred string) string { if len(profiles) == 0 { return "" } normalizedPreferred := strings.TrimSpace(preferred) if normalizedPreferred != "" { if _, ok := profiles[normalizedPreferred]; ok { return normalizedPreferred } } if _, ok := profiles[fallbackProfile]; ok { return fallbackProfile } names := make([]string, 0, len(profiles)) for name := range profiles { if trimmed := strings.TrimSpace(name); trimmed != "" { names = append(names, trimmed) } } if len(names) == 0 { return "" } sort.Strings(names) return names[0] } 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 setup`", 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 } } func renderSource(field frameworkcli.ResolvedField) string { if !field.Found { return "missing" } return string(field.Source) } func renderVisibleField(field frameworkcli.ResolvedField) string { if !field.Found || strings.TrimSpace(field.Value) == "" { return "" } return field.Value } func renderSecretField(field frameworkcli.ResolvedField) string { if !field.Found || strings.TrimSpace(field.Value) == "" { return "" } return "" } 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, } }