email-mcp/internal/cli/app.go

400 lines
10 KiB
Go
Raw Normal View History

2026-04-10 07:34:33 +00:00
package cli
import (
"context"
2026-04-10 08:47:52 +00:00
"errors"
2026-04-13 16:01:28 +00:00
"flag"
"fmt"
"io"
2026-04-13 16:01:28 +00:00
"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"
2026-04-10 07:34:33 +00:00
"email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore"
2026-04-13 16:01:28 +00:00
)
const (
binaryName = "email-mcp"
defaultProfileEnv = "EMAIL_MCP_PROFILE"
)
2026-04-10 07:34:33 +00:00
type MCPRunner interface {
Run(ctx context.Context) error
}
2026-04-13 16:01:28 +00:00
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 {
2026-04-13 16:01:28 +00:00
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 {
2026-04-13 16:01:28 +00:00
return BuildApp("dev")
}
2026-04-13 16:01:28 +00:00
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
}
2026-04-13 16:01:28 +00:00
if version == "" {
version = "dev"
}
return &App{
2026-04-13 16:01:28 +00:00
prompter: prompter,
configStore: configStore,
openSecretStore: openSecretStore,
newMailService: newMailService,
newRunner: newRunner,
loadManifest: loadManifest,
resolveExecutable: resolveExecutable,
stdin: stdin,
stdout: stdout,
stderr: stderr,
version: version,
}
2026-04-10 07:34:33 +00:00
}
func (a *App) Run(args []string) error {
if len(args) == 0 {
2026-04-13 16:01:28 +00:00
return fmt.Errorf("usage: email-mcp <config|setup|mcp|update>")
2026-04-10 07:34:33 +00:00
}
switch args[0] {
2026-04-13 16:01:28 +00:00
case "config", "setup":
return a.runConfig(context.Background(), args[0], args[1:])
case "mcp":
2026-04-13 16:01:28 +00:00
return a.runMCP(context.Background(), args[1:])
case "update":
return a.runUpdate(context.Background(), args[1:])
2026-04-10 07:34:33 +00:00
default:
return fmt.Errorf("unknown command: %s", args[0])
}
}
2026-04-13 16:01:28 +00:00
func (a *App) runConfig(ctx context.Context, command string, args []string) error {
if a.prompter == nil {
2026-04-13 16:01:28 +00:00
return fmt.Errorf("config prompter is not configured")
}
2026-04-13 16:01:28 +00:00
if a.configStore == nil {
return fmt.Errorf("config store is not configured")
}
if a.openSecretStore == nil {
return fmt.Errorf("secret store is not configured")
}
2026-04-13 16:01:28 +00:00
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
}
2026-04-13 16:01:28 +00:00
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
2026-04-10 08:47:52 +00:00
return mapAppError(err)
}
2026-04-13 16:01:28 +00:00
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
}
2026-04-13 16:01:28 +00:00
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")
}
2026-04-13 16:01:28 +00:00
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
2026-04-10 08:47:52 +00:00
}
func mapAppError(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
2026-04-13 16:01:28 +00:00
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)
2026-04-10 08:47:52 +00:00
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,
}
}