package cli import ( "context" "errors" "flag" "fmt" "io" "os" "path/filepath" "strings" 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" ) 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 len(args) == 0 { return fmt.Errorf("usage: email-mcp ") } switch args[0] { case "config", "setup": return a.runConfig(context.Background(), args[0], args[1:]) case "mcp": return a.runMCP(context.Background(), args[1:]) case "doctor": return a.runDoctor(context.Background(), args[1:]) case "update": return a.runUpdate(context.Background(), args[1:]) default: return fmt.Errorf("unknown command: %s", args[0]) } } 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, ok := cfg.Profiles[profileName] if !ok { return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName) } secrets, err := a.openSecretStore() if err != nil { return secretstore.Credential{}, err } password, _, err := loadStoredPassword(secrets, profileName) if err != nil { if errors.Is(err, frameworksecretstore.ErrNotFound) { return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName) } return secretstore.Credential{}, err } cred := secretstore.Credential{ Host: profile.Host, Username: profile.Username, Password: password, } if err := cred.Validate(); err != nil { return secretstore.Credential{}, fmt.Errorf("%w: profile %q is incomplete", mcpserver.ErrCredentialsNotConfigured, profileName) } return cred, 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, } }