From a2cc90a8a230bd36aae0acc898dfa16a11c44f23 Mon Sep 17 00:00:00 2001 From: nonozone Date: Mon, 6 Jul 2026 17:44:22 +0800 Subject: [PATCH] feat: make MailCLI Go-only and add config diagnostics Move official agent examples and maintainer workflows to Go, remove the legacy Python example path, and add config init/doctor/capabilities contracts for existing-mailbox setup. Config diagnostics now keep raw secret references separate from resolved values so unset environment variables are reported without leaking secrets. --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/test.yml | 9 - CONTRIBUTING.md | 3 +- CONTRIBUTING.zh-CN.md | 3 +- Makefile | 4 +- README.md | 25 +- README.zh-CN.md | 28 +- cmd/config.go | 582 ++++++++++ cmd/config_test.go | 439 +++++++ cmd/watch.go | 2 +- docs/en/agent-workflows.md | 2 +- docs/en/examples/README.md | 20 +- docs/en/examples/agent-inbox-assistant.md | 17 +- docs/en/examples/agent-thread-assistant.md | 17 +- docs/en/examples/local-thread-demo.md | 8 +- docs/en/examples/openai-external-provider.md | 23 +- docs/en/project/github-backlog.md | 27 +- docs/en/project/internal-priority.md | 94 +- docs/en/project/next-roadmap.md | 145 ++- docs/en/release/announcement-kit.md | 2 +- docs/en/release/github-v0.1.0-rc1.md | 5 +- docs/en/release/v0.1-rc.md | 4 +- docs/en/release/v0.1.0.md | 10 +- docs/en/spec/agent-provider.md | 12 +- docs/en/spec/config.md | 117 ++ docs/en/spec/outbound-message.md | 2 +- docs/en/spec/watch.md | 10 +- .../plans/2026-03-26-agent-inbox-example.md | 8 +- docs/superpowers/plans/2026-03-26-v0.1-rc.md | 7 +- .../2026-03-26-mailcli-positioning-design.md | 8 +- docs/zh-CN/agent-workflows.md | 2 +- docs/zh-CN/examples/README.md | 20 +- docs/zh-CN/examples/agent-inbox-assistant.md | 17 +- docs/zh-CN/examples/agent-thread-assistant.md | 17 +- docs/zh-CN/examples/local-thread-demo.md | 8 +- .../examples/openai-external-provider.md | 23 +- docs/zh-CN/project/github-backlog.md | 27 +- docs/zh-CN/project/internal-priority.md | 92 +- docs/zh-CN/project/next-roadmap.md | 145 ++- docs/zh-CN/release/announcement-kit.md | 2 +- docs/zh-CN/release/github-v0.1.0-rc1.md | 5 +- docs/zh-CN/release/v0.1-rc.md | 4 +- docs/zh-CN/release/v0.1.0.md | 10 +- docs/zh-CN/spec/agent-provider.md | 12 +- docs/zh-CN/spec/config.md | 117 ++ docs/zh-CN/spec/outbound-message.md | 2 +- docs/zh-CN/spec/watch.md | 8 +- .../local-thread-demo/agent-report.json | 342 +++--- .../local-thread-demo/reply.draft.json | 8 +- .../artifacts/local-thread-demo/sync.json | 8 +- .../artifacts/local-thread-demo/thread.json | 50 +- .../artifacts/local-thread-demo/threads.json | 52 +- examples/examples_test.go | 1018 +++++------------ examples/go/agent_inbox_assistant/main.go | 246 ++++ examples/go/agent_thread_assistant/main.go | 466 ++++++++ examples/go/parse_email/main.go | 31 + .../openai_external_provider/main.go | 250 ++++ .../template_external_provider/main.go | 22 + examples/go/refresh_local_thread_demo/main.go | 500 ++++++++ examples/go/reply_dry_run/main.go | 22 + examples/go/watch_reply_agent/main.go | 225 ++++ examples/internal/agent/agent.go | 267 +++++ .../providers/openai_external_provider.py | 158 --- .../providers/template_external_provider.py | 67 -- .../provider_contract.cpython-314.pyc | Bin 1904 -> 0 bytes examples/python/agent_inbox_assistant.py | 194 ---- examples/python/agent_thread_assistant.py | 340 ------ examples/python/parse_email.py | 24 - examples/python/provider_contract.py | 32 - examples/python/refresh_local_thread_demo.py | 433 ------- examples/python/reply_dry_run.py | 22 - internal/config/config.go | 34 +- internal/config/config_test.go | 29 + pkg/schema/capabilities.go | 29 + pkg/schema/config_diagnostics.go | 30 + tools/README.md | 183 ++- tools/agent_example.py | 162 --- 78 files changed, 4446 insertions(+), 2946 deletions(-) create mode 100644 cmd/config_test.go create mode 100644 examples/go/agent_inbox_assistant/main.go create mode 100644 examples/go/agent_thread_assistant/main.go create mode 100644 examples/go/parse_email/main.go create mode 100644 examples/go/providers/openai_external_provider/main.go create mode 100644 examples/go/providers/template_external_provider/main.go create mode 100644 examples/go/refresh_local_thread_demo/main.go create mode 100644 examples/go/reply_dry_run/main.go create mode 100644 examples/go/watch_reply_agent/main.go create mode 100644 examples/internal/agent/agent.go delete mode 100644 examples/providers/openai_external_provider.py delete mode 100644 examples/providers/template_external_provider.py delete mode 100644 examples/python/__pycache__/provider_contract.cpython-314.pyc delete mode 100644 examples/python/agent_inbox_assistant.py delete mode 100644 examples/python/agent_thread_assistant.py delete mode 100644 examples/python/parse_email.py delete mode 100644 examples/python/provider_contract.py delete mode 100644 examples/python/refresh_local_thread_demo.py delete mode 100644 examples/python/reply_dry_run.py create mode 100644 pkg/schema/capabilities.go create mode 100644 pkg/schema/config_diagnostics.go delete mode 100644 tools/agent_example.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 561817d..eac2aeb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,7 +30,7 @@ Describe the bug clearly. - OS: - Go version: -- Python version (if examples are involved): +- MailCLI version or commit: ## Evidence diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1e269bc..fa97762 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ Describe the change in 2-5 sentences. - [ ] Tests were added or updated where behavior changed - [ ] `go test ./...` passes locally - [ ] `go build ./cmd/mailcli` passes locally -- [ ] Python examples still compile when touched +- [ ] Go examples and demo artifacts were updated when touched - [ ] User-facing docs were updated when contracts or workflows changed - [ ] English and Chinese docs were both updated when needed diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4fb4373..a8a635a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,11 +17,6 @@ jobs: with: go-version-file: go.mod - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - name: Run Go tests run: make test @@ -30,7 +25,3 @@ jobs: - name: Check local thread demo artifacts run: make demo-local-thread-check - - - name: Verify Python examples compile - run: | - python3 -m py_compile $(find examples/python -name '*.py' -print) $(find examples/providers -name '*.py' -print) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f68b041..b35129e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,8 @@ The goal is not to become a traditional terminal mail client. The goal is to bui ```bash go test ./... go build ./cmd/mailcli -python3 -m py_compile examples/python/*.py examples/providers/*.py +go test ./examples +make demo-local-thread-check ``` ## Pull Requests diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index b1478f4..03c54e8 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -35,7 +35,8 @@ MailCLI 是一个开源的 AI Native 邮件接口项目。 ```bash go test ./... go build ./cmd/mailcli -python3 -m py_compile examples/python/*.py examples/providers/*.py +go test ./examples +make demo-local-thread-check ``` ## Pull Request 请包含 diff --git a/Makefile b/Makefile index 289b854..32d998e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ test: go test ./... demo-local-thread-refresh: build - PYTHONDONTWRITEBYTECODE=1 python3 examples/python/refresh_local_thread_demo.py \ + go run ./examples/go/refresh_local_thread_demo \ --mailcli-bin $(MAILCLI_BIN) \ --config $(FIXTURES_CONFIG) \ --account $(FIXTURES_ACCOUNT) \ @@ -21,7 +21,7 @@ demo-local-thread-refresh: build --output-dir $(LOCAL_THREAD_DEMO_DIR) demo-local-thread-check: build - PYTHONDONTWRITEBYTECODE=1 python3 examples/python/refresh_local_thread_demo.py \ + go run ./examples/go/refresh_local_thread_demo \ --mailcli-bin $(MAILCLI_BIN) \ --config $(FIXTURES_CONFIG) \ --account $(FIXTURES_ACCOUNT) \ diff --git a/README.md b/README.md index 8a26806..8698f72 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ go build -o mailcli ./cmd/mailcli ./mailcli threads --index /tmp/mailcli-fixtures-index.db invoice # 3. inspect the full agent boundary -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -91,7 +91,7 @@ The repository already includes: - a local fixture corpus under `testdata/emails` - a zero-network config at `examples/config/fixtures-dir.yaml` -- runnable Python examples +- runnable Go examples under `examples/go` - a full local round-trip demo at [Local Thread Demo](docs/en/examples/local-thread-demo.md) - fixed outbound JSON and MIME pairs at [Outbound Draft Patterns](docs/en/examples/outbound-draft-patterns.md) @@ -127,8 +127,8 @@ Working today: - delete, move, mark-read/unread on remote mailboxes - export the local index as JSONL, JSON, or CSV - **watch** one or more mailboxes with IMAP IDLE push (streaming JSONL event feed, persistent seen state across restarts) -- manage config with `mailcli config show` / `mailcli config test` -- integrate with Python or shell agent workflows through stable JSON contracts +- create, inspect, diagnose, test, and inspect account capabilities with `mailcli config init` / `mailcli config show` / `mailcli config doctor` / `mailcli config test` / `mailcli config capabilities` +- integrate with Go, shell, and external agent workflows through stable JSON contracts - LLM tool-use schemas for OpenAI and Anthropic (`tools/` directory) Stable enough to build against for `v0.1 RC`: @@ -147,7 +147,7 @@ Stable enough to build against for `v0.1 RC`: - `mailcli mark` - `mailcli export` - `mailcli watch` -- `mailcli config show|test` +- `mailcli config init|show|doctor|test|capabilities` - `StandardMessage` - `DraftMessage` - `ReplyDraft` @@ -268,13 +268,18 @@ MailCLI solves that by providing a stable boundary: ```bash # Pipe to AI agent with persistent deduplication: mailcli watch --account work --index ~/.config/mailcli/index.db \ - | python3 tools/agent_example.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` ### Config management +- `mailcli config init [--config] --account --driver imap --host --username --password-env ` — create a starter config file that stores secret environment references such as `${MAILCLI_IMAP_PASSWORD}`, not raw passwords - `mailcli config show [--config]` — print accounts (passwords redacted) +- `mailcli config doctor [--config]` — run local static diagnostics without connecting to IMAP or SMTP - `mailcli config test [--config] [--account]` — test live connection +- `mailcli config capabilities [--config] [--account]` — print machine-readable account capabilities without connecting to the mailbox server + +`config init`, `config doctor`, and `config capabilities` are safe onboarding commands for agents and setup scripts. They do not print configured password values. `config doctor` distinguishes missing secret references from unset environment variables such as `imap_password_env_unset`. `config test` is the command that performs a live mailbox connection check. ### Outbound Markdown baseline @@ -461,7 +466,7 @@ cat test.eml | mailcli parse --format json - If you want the full agent-side JSON and reply boundary, use: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -470,7 +475,7 @@ python3 examples/python/agent_thread_assistant.py \ --query invoice ``` -If you want fixed JSON and MIME pairs for outbound composition without reading Python code, use: +If you want fixed JSON and MIME pairs for outbound composition without reading example code, use: ```bash ./mailcli reply --config examples/config/fixtures-dir.yaml --account fixtures --dry-run examples/artifacts/outbound-patterns/ack-reply.draft.json @@ -561,7 +566,7 @@ mailcli reply --dry-run reply.json ### Run the agent example ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/verification.eml ``` @@ -569,7 +574,7 @@ python3 examples/python/agent_inbox_assistant.py \ ### Run the thread agent example ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config ~/.config/mailcli/config.yaml \ --account work \ diff --git a/README.zh-CN.md b/README.zh-CN.md index fa09f5f..20b5a63 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,7 +64,7 @@ go build -o mailcli ./cmd/mailcli ./mailcli threads --index /tmp/mailcli-fixtures-index.db invoice # 3. 查看完整的 agent 边界 -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -91,7 +91,7 @@ flowchart LR - `testdata/emails` 下的本地 fixture 语料 - 零网络配置 `examples/config/fixtures-dir.yaml` -- 可直接运行的 Python 示例 +- `examples/go` 下可直接运行的 Go 示例 - 一份完整的本地往返说明:[Local Thread Demo](docs/zh-CN/examples/local-thread-demo.md) - 一组固定的出站 JSON / MIME 对照样例:[Outbound Draft Patterns](docs/zh-CN/examples/outbound-draft-patterns.md) @@ -127,8 +127,8 @@ MailCLI 当前处于 **pre-v0.1 release candidate** 阶段。 - 删除、移动、读/未读标记远端邮件 - 将本地索引导出为 JSONL、JSON 或 CSV - **watch** 一个或多个邮箱(IMAP IDLE 推送事件流,重启后持久化去重) -- `mailcli config show` / `mailcli config test` 管理配置 -- 通过稳定 JSON 契约与 Python / shell agent 工作流协作 +- `mailcli config init` / `mailcli config show` / `mailcli config doctor` / `mailcli config test` / `mailcli config capabilities` 创建、查看、诊断、测试配置与账户能力 +- 通过稳定 JSON 契约与 Go、shell 和外部 agent 工作流协作 - OpenAI 和 Anthropic 格式的 LLM Tool Use Schema(`tools/` 目录) 在 `v0.1 RC` 阶段,已经足够作为稳定集成边界的部分: @@ -147,7 +147,7 @@ MailCLI 当前处于 **pre-v0.1 release candidate** 阶段。 - `mailcli mark` - `mailcli export` - `mailcli watch` -- `mailcli config show|test` +- `mailcli config init|show|doctor|test|capabilities` - `StandardMessage` - `DraftMessage` - `ReplyDraft` @@ -254,6 +254,16 @@ MailCLI 提供的是一个稳定边界: - `mailcli reply --dry-run ` - `mailcli reply --config ~/.config/mailcli/config.yaml ` +### 配置与能力发现 + +- `mailcli config init [--config] --account --driver imap --host --username --password-env ` +- `mailcli config show [--config]` +- `mailcli config doctor [--config]` +- `mailcli config test [--config] [--account]` +- `mailcli config capabilities [--config] [--account]` + +`config init` 会生成 starter YAML,并把秘密值写成 `${MAILCLI_IMAP_PASSWORD}` 这样的环境变量引用,而不是原始密码。`config doctor` 做本地静态诊断,不连接 IMAP / SMTP,并能区分“没有写 secret 引用”和“引用存在但环境变量未设置”,例如 `imap_password_env_unset`。`config capabilities` 输出稳定 JSON,帮助 Agent 在执行 `send`、`watch`、`delete` 等命令前判断当前账户能力。它只读取本地配置和内置 driver 的已知能力,不连接邮箱服务器,也不输出密码。真正联网检查连接的是 `config test`。 + ### 出站 Markdown 基线 - 标题 @@ -438,7 +448,7 @@ cat test.eml | mailcli parse --format json - 如果你想直接看 agent 侧完整 JSON 和 reply 边界,可以运行: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -447,7 +457,7 @@ python3 examples/python/agent_thread_assistant.py \ --query invoice ``` -如果你想在不读 Python 示例的情况下直接查看固定的出站 JSON / MIME 对照,可以运行: +如果你想在不读示例代码的情况下直接查看固定的出站 JSON / MIME 对照,可以运行: ```bash ./mailcli reply --config examples/config/fixtures-dir.yaml --account fixtures --dry-run examples/artifacts/outbound-patterns/ack-reply.draft.json @@ -538,7 +548,7 @@ mailcli reply --dry-run reply.json ### 运行 agent 示例 ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/verification.eml ``` @@ -546,7 +556,7 @@ python3 examples/python/agent_inbox_assistant.py \ ### 运行 thread agent 示例 ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config ~/.config/mailcli/config.yaml \ --account work \ diff --git a/cmd/config.go b/cmd/config.go index 7efa31d..597c924 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -19,11 +21,244 @@ func newConfigCmd() *cobra.Command { }, } + cmd.AddCommand(newConfigInitCmd()) cmd.AddCommand(newConfigShowCmd()) + cmd.AddCommand(newConfigDoctorCmd()) cmd.AddCommand(newConfigTestCmd()) + cmd.AddCommand(newConfigCapabilitiesCmd()) return cmd } +// config init ───────────────────────────────────────────────────────────────── + +func newConfigInitCmd() *cobra.Command { + var ( + configPath string + account string + driverName string + host string + port int + username string + passwordEnv string + tlsEnabled bool + mailbox string + smtpHost string + smtpPort int + smtpUsername string + smtpPasswordEnv string + smtpTLSEnabled bool + dirPath string + force bool + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Create a starter mailcli config file", + RunE: func(cmd *cobra.Command, args []string) error { + if configPath == "" { + configPath = config.DefaultPath() + } + + driverName = strings.ToLower(strings.TrimSpace(driverName)) + account = strings.TrimSpace(account) + if account == "" { + return fmt.Errorf("--account is required") + } + if driverName == "" { + return fmt.Errorf("--driver is required") + } + + if _, err := os.Stat(configPath); err == nil && !force { + return fmt.Errorf("config already exists at %s; pass --force to overwrite", configPath) + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("stat config: %w", err) + } + + accountConfig, err := buildInitialAccountConfig(initialAccountOptions{ + account: account, + driverName: driverName, + host: host, + port: port, + username: username, + passwordEnv: passwordEnv, + tlsEnabled: tlsEnabled, + mailbox: mailbox, + smtpHost: smtpHost, + smtpPort: smtpPort, + smtpUsername: smtpUsername, + smtpPasswordEnv: smtpPasswordEnv, + smtpTLSEnabled: smtpTLSEnabled, + dirPath: dirPath, + }) + if err != nil { + return err + } + + cfg := config.Config{ + CurrentAccount: account, + Accounts: []config.AccountConfig{accountConfig}, + } + data, err := config.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + if err := os.WriteFile(configPath, data, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + if err := os.Chmod(configPath, 0o600); err != nil { + return fmt.Errorf("set config permissions: %w", err) + } + + return writeJSON(cmd.OutOrStdout(), schema.ConfigInitResult{ + Status: "created", + ConfigPath: configPath, + Account: accountConfig.Name, + Driver: accountConfig.Driver, + }) + }, + } + + cmd.Flags().StringVar(&configPath, "config", "", "config file path") + cmd.Flags().StringVar(&account, "account", "", "account name") + cmd.Flags().StringVar(&driverName, "driver", "", "driver type: imap, dir, stub") + cmd.Flags().StringVar(&host, "host", "", "IMAP host") + cmd.Flags().IntVar(&port, "port", 993, "IMAP port") + cmd.Flags().StringVar(&username, "username", "", "IMAP username") + cmd.Flags().StringVar(&passwordEnv, "password-env", "", "environment variable name for the IMAP password") + cmd.Flags().BoolVar(&tlsEnabled, "tls", true, "enable TLS for IMAP") + cmd.Flags().StringVar(&mailbox, "mailbox", "INBOX", "default mailbox") + cmd.Flags().StringVar(&smtpHost, "smtp-host", "", "SMTP host") + cmd.Flags().IntVar(&smtpPort, "smtp-port", 587, "SMTP port") + cmd.Flags().StringVar(&smtpUsername, "smtp-username", "", "SMTP username; defaults to --username when omitted") + cmd.Flags().StringVar(&smtpPasswordEnv, "smtp-password-env", "", "environment variable name for the SMTP password") + cmd.Flags().BoolVar(&smtpTLSEnabled, "smtp-tls", true, "enable TLS for SMTP") + cmd.Flags().StringVar(&dirPath, "path", "", "local .eml directory for dir driver") + cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing config file") + return cmd +} + +type initialAccountOptions struct { + account string + driverName string + host string + port int + username string + passwordEnv string + tlsEnabled bool + mailbox string + smtpHost string + smtpPort int + smtpUsername string + smtpPasswordEnv string + smtpTLSEnabled bool + dirPath string +} + +func buildInitialAccountConfig(opts initialAccountOptions) (config.AccountConfig, error) { + account := config.AccountConfig{ + Name: opts.account, + Driver: opts.driverName, + Mailbox: strings.TrimSpace(opts.mailbox), + } + if account.Mailbox == "" { + account.Mailbox = "INBOX" + } + + switch opts.driverName { + case "imap": + if strings.TrimSpace(opts.host) == "" { + return config.AccountConfig{}, fmt.Errorf("--host is required for imap config") + } + if opts.port <= 0 { + return config.AccountConfig{}, fmt.Errorf("--port must be greater than 0") + } + if strings.TrimSpace(opts.username) == "" { + return config.AccountConfig{}, fmt.Errorf("--username is required for imap config") + } + if strings.TrimSpace(opts.passwordEnv) == "" { + return config.AccountConfig{}, fmt.Errorf("--password-env is required for imap config") + } + if !validEnvName(opts.passwordEnv) { + return config.AccountConfig{}, fmt.Errorf("--password-env must be a valid environment variable name") + } + account.Host = strings.TrimSpace(opts.host) + account.Port = opts.port + account.Username = strings.TrimSpace(opts.username) + account.Password = envReference(opts.passwordEnv) + account.TLS = opts.tlsEnabled + + if strings.TrimSpace(opts.smtpHost) != "" || opts.smtpPort != 587 || strings.TrimSpace(opts.smtpUsername) != "" || strings.TrimSpace(opts.smtpPasswordEnv) != "" { + if strings.TrimSpace(opts.smtpHost) == "" { + return config.AccountConfig{}, fmt.Errorf("--smtp-host is required when SMTP options are provided") + } + if opts.smtpPort <= 0 { + return config.AccountConfig{}, fmt.Errorf("--smtp-port must be greater than 0") + } + if strings.TrimSpace(opts.smtpPasswordEnv) == "" { + return config.AccountConfig{}, fmt.Errorf("--smtp-password-env is required when SMTP options are provided") + } + if !validEnvName(opts.smtpPasswordEnv) { + return config.AccountConfig{}, fmt.Errorf("--smtp-password-env must be a valid environment variable name") + } + account.SMTPHost = strings.TrimSpace(opts.smtpHost) + account.SMTPPort = opts.smtpPort + account.SMTPUsername = strings.TrimSpace(opts.smtpUsername) + if account.SMTPUsername == "" { + account.SMTPUsername = account.Username + } + account.SMTPPassword = envReference(opts.smtpPasswordEnv) + account.SMTPTLS = opts.smtpTLSEnabled + } + case "dir": + if strings.TrimSpace(opts.dirPath) == "" { + return config.AccountConfig{}, fmt.Errorf("--path is required for dir config") + } + account.Path = strings.TrimSpace(opts.dirPath) + account.Username = strings.TrimSpace(opts.username) + case "stub": + account.Username = strings.TrimSpace(opts.username) + default: + return config.AccountConfig{}, fmt.Errorf("unsupported driver: %s", opts.driverName) + } + + return account, nil +} + +func envReference(name string) string { + return "${" + strings.TrimSpace(name) + "}" +} + +func validEnvName(name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + for i, r := range name { + if i == 0 { + if !isEnvNameStart(r) { + return false + } + continue + } + if !isEnvNamePart(r) { + return false + } + } + return true +} + +func isEnvNameStart(r rune) bool { + return r == '_' || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z') +} + +func isEnvNamePart(r rune) bool { + return isEnvNameStart(r) || ('0' <= r && r <= '9') +} + // config show ───────────────────────────────────────────────────────────────── func newConfigShowCmd() *cobra.Command { @@ -120,3 +355,350 @@ func newConfigTestCmd() *cobra.Command { cmd.Flags().StringVar(&account, "account", "", "account name to test") return cmd } + +// config capabilities ───────────────────────────────────────────────────────── + +func newConfigCapabilitiesCmd() *cobra.Command { + var ( + configPath string + account string + ) + + cmd := &cobra.Command{ + Use: "capabilities", + Short: "Print machine-readable capabilities for the selected account", + RunE: func(cmd *cobra.Command, args []string) error { + selectedAccount, err := resolveSelectedAccount(configPath, account, "") + if err != nil { + return err + } + + result := accountCapabilities(selectedAccount) + return writeJSON(cmd.OutOrStdout(), &result) + }, + } + + cmd.Flags().StringVar(&configPath, "config", "", "config file path") + cmd.Flags().StringVar(&account, "account", "", "account name to inspect") + return cmd +} + +// config doctor ─────────────────────────────────────────────────────────────── + +func newConfigDoctorCmd() *cobra.Command { + var configPath string + + cmd := &cobra.Command{ + Use: "doctor", + Short: "Validate local configuration without connecting to mailbox servers", + RunE: func(cmd *cobra.Command, args []string) error { + if configPath == "" { + configPath = config.DefaultPath() + } + + rawCfg, err := config.LoadRaw(configPath) + if err != nil { + return fmt.Errorf("load raw config: %w", err) + } + + cfg, err := loadConfigFunc(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + result := diagnoseConfig(configPath, rawCfg, cfg) + return writeJSON(cmd.OutOrStdout(), &result) + }, + } + + cmd.Flags().StringVar(&configPath, "config", "", "config file path") + return cmd +} + +func diagnoseConfig(configPath string, rawCfg, resolvedCfg config.Config) schema.ConfigDiagnostics { + result := schema.ConfigDiagnostics{ + ConfigPath: configPath, + Status: "ok", + Accounts: make([]schema.AccountDiagnostic, 0, len(rawCfg.Accounts)), + } + + if strings.TrimSpace(rawCfg.CurrentAccount) == "" { + result.Problems = append(result.Problems, schema.ConfigDiagnostic{ + Status: "warning", + Code: "current_account_missing", + Message: "current_account is not set; commands will require --account", + Field: "current_account", + }) + } else if !configHasAccount(rawCfg, rawCfg.CurrentAccount) { + result.Problems = append(result.Problems, schema.ConfigDiagnostic{ + Status: "error", + Code: "current_account_not_found", + Message: "current_account does not match any configured account", + Field: "current_account", + }) + } + if len(rawCfg.Accounts) == 0 { + result.Problems = append(result.Problems, schema.ConfigDiagnostic{ + Status: "error", + Code: "accounts_missing", + Message: "no accounts are configured", + Field: "accounts", + }) + } + + for i, rawAccount := range rawCfg.Accounts { + resolvedAccount := rawAccount + if i < len(resolvedCfg.Accounts) { + resolvedAccount = resolvedCfg.Accounts[i] + } + accountResult := diagnoseAccount(rawAccount, resolvedAccount) + result.Accounts = append(result.Accounts, accountResult) + for _, check := range accountResult.Checks { + if check.Status == "warning" || check.Status == "error" { + result.Problems = append(result.Problems, check) + } + } + } + + result.Status = aggregateDiagnosticsStatus(result.Problems) + return result +} + +func configHasAccount(cfg config.Config, name string) bool { + target := strings.TrimSpace(name) + for _, account := range cfg.Accounts { + if strings.TrimSpace(account.Name) == target { + return true + } + } + return false +} + +func diagnoseAccount(rawAccount, resolvedAccount config.AccountConfig) schema.AccountDiagnostic { + driverName := strings.ToLower(strings.TrimSpace(rawAccount.Driver)) + result := schema.AccountDiagnostic{ + Name: rawAccount.Name, + Driver: driverName, + Status: "ok", + Capabilities: accountCapabilities(resolvedAccount), + } + + if strings.TrimSpace(rawAccount.Name) == "" { + result.Checks = append(result.Checks, diagnostic("error", "account_name_missing", "account name is required", "name")) + } + if driverName == "" { + result.Checks = append(result.Checks, diagnostic("error", "driver_missing", "driver is required", "driver")) + result.Status = aggregateDiagnosticsStatus(result.Checks) + return result + } + + switch driverName { + case "imap": + result.Checks = append(result.Checks, requireString(rawAccount.Host, "imap_host_missing", "IMAP host is required", "host")) + result.Checks = append(result.Checks, requirePort(rawAccount.Port, "imap_port_missing", "IMAP port must be greater than 0", "port")) + result.Checks = append(result.Checks, requireString(rawAccount.Username, "imap_username_missing", "IMAP username is required", "username")) + result.Checks = append(result.Checks, requireRequiredSecret( + rawAccount.Password, + resolvedAccount.Password, + "imap_password_missing", + "imap_password_env_unset", + "IMAP password or password env reference is required", + "IMAP password environment reference is not set", + "password", + )) + result.Checks = append(result.Checks, diagnoseSMTP(rawAccount, resolvedAccount)...) + case "dir": + result.Checks = append(result.Checks, requireString(rawAccount.Path, "dir_path_missing", "dir driver requires path", "path")) + case "stub": + result.Checks = append(result.Checks, diagnostic("ok", "stub_configured", "stub driver is always locally available", "driver")) + default: + result.Checks = append(result.Checks, diagnostic("error", "driver_unsupported", "driver must be one of: imap, dir, stub", "driver")) + } + + result.Status = aggregateDiagnosticsStatus(result.Checks) + return result +} + +func diagnoseSMTP(rawAccount, resolvedAccount config.AccountConfig) []schema.ConfigDiagnostic { + if strings.TrimSpace(rawAccount.SMTPHost) == "" && rawAccount.SMTPPort == 0 && strings.TrimSpace(rawAccount.SMTPUsername) == "" && strings.TrimSpace(rawAccount.SMTPPassword) == "" { + return []schema.ConfigDiagnostic{diagnostic("warning", "smtp_not_configured", "SMTP is not configured; send and reply are disabled", "smtp_host")} + } + + checks := []schema.ConfigDiagnostic{ + requireOptionalString(rawAccount.SMTPHost, "smtp_host_missing", "SMTP host is required for outbound mail", "smtp_host"), + requireOptionalPort(rawAccount.SMTPPort, "smtp_port_missing", "SMTP port must be greater than 0", "smtp_port"), + } + + if strings.TrimSpace(firstNonEmpty(rawAccount.SMTPUsername, rawAccount.Username)) == "" { + checks = append(checks, diagnostic("warning", "smtp_username_missing", "SMTP username is required or must inherit from username", "smtp_username")) + } + checks = append(checks, requireOptionalSecret( + firstNonEmpty(rawAccount.SMTPPassword, rawAccount.Password), + firstNonEmpty(resolvedAccount.SMTPPassword, resolvedAccount.Password), + "smtp_password_missing", + "smtp_password_env_unset", + "SMTP password or password env reference is required for outbound mail", + "SMTP password environment reference is not set", + "smtp_password", + )) + return checks +} + +func requireRequiredSecret(rawValue, resolvedValue, missingCode, envUnsetCode, missingMessage, envUnsetMessage, field string) schema.ConfigDiagnostic { + if strings.TrimSpace(rawValue) == "" { + return diagnostic("error", missingCode, missingMessage, field) + } + if strings.TrimSpace(resolvedValue) == "" { + return diagnostic("error", envUnsetCode, envUnsetMessage, field) + } + return diagnostic("ok", strings.TrimSuffix(missingCode, "_missing")+"_present", field+" is configured", field) +} + +func requireOptionalSecret(rawValue, resolvedValue, missingCode, envUnsetCode, missingMessage, envUnsetMessage, field string) schema.ConfigDiagnostic { + if strings.TrimSpace(rawValue) == "" { + return diagnostic("warning", missingCode, missingMessage, field) + } + if strings.TrimSpace(resolvedValue) == "" { + return diagnostic("warning", envUnsetCode, envUnsetMessage, field) + } + return diagnostic("ok", strings.TrimSuffix(missingCode, "_missing")+"_present", field+" is configured", field) +} + +func requireOptionalString(value, code, message, field string) schema.ConfigDiagnostic { + if strings.TrimSpace(value) == "" { + return diagnostic("warning", code, message, field) + } + return diagnostic("ok", strings.TrimSuffix(code, "_missing")+"_present", field+" is configured", field) +} + +func requireOptionalPort(value int, code, message, field string) schema.ConfigDiagnostic { + if value <= 0 { + return diagnostic("warning", code, message, field) + } + return diagnostic("ok", strings.TrimSuffix(code, "_missing")+"_present", field+" is configured", field) +} + +func requireString(value, code, message, field string) schema.ConfigDiagnostic { + if strings.TrimSpace(value) == "" { + return diagnostic("error", code, message, field) + } + return diagnostic("ok", strings.TrimSuffix(code, "_missing")+"_present", strings.TrimSuffix(message, " is required")+" is configured", field) +} + +func requirePort(value int, code, message, field string) schema.ConfigDiagnostic { + if value <= 0 { + return diagnostic("error", code, message, field) + } + return diagnostic("ok", strings.TrimSuffix(code, "_missing")+"_present", field+" is configured", field) +} + +func diagnostic(status, code, message, field string) schema.ConfigDiagnostic { + return schema.ConfigDiagnostic{ + Status: status, + Code: code, + Message: message, + Field: field, + } +} + +func aggregateDiagnosticsStatus(checks []schema.ConfigDiagnostic) string { + status := "ok" + for _, check := range checks { + switch check.Status { + case "error": + return "error" + case "warning": + status = "warning" + } + } + return status +} + +func accountCapabilities(account config.AccountConfig) schema.AccountCapabilities { + driverName := strings.ToLower(strings.TrimSpace(account.Driver)) + mailbox := strings.TrimSpace(account.Mailbox) + if mailbox == "" { + mailbox = "INBOX" + } + + result := schema.AccountCapabilities{ + Account: account.Name, + Driver: driverName, + Mailbox: mailbox, + Configuration: schema.AccountCapabilityConfiguration{ + InboundConfigured: inboundConfigured(account), + OutboundConfigured: outboundConfigured(account), + UsesLocalStorage: driverName == "dir", + }, + } + + switch driverName { + case "imap": + result.Capabilities = schema.MailCapabilities{ + List: result.Configuration.InboundConfigured, + FetchRaw: result.Configuration.InboundConfigured, + Search: result.Configuration.InboundConfigured, + Threads: result.Configuration.InboundConfigured, + Watch: result.Configuration.InboundConfigured, + Send: result.Configuration.OutboundConfigured, + Reply: result.Configuration.OutboundConfigured, + Delete: result.Configuration.InboundConfigured, + Move: result.Configuration.InboundConfigured, + MarkRead: result.Configuration.InboundConfigured, + LocalIndex: result.Configuration.InboundConfigured, + } + case "dir": + result.Capabilities = schema.MailCapabilities{ + List: result.Configuration.InboundConfigured, + FetchRaw: result.Configuration.InboundConfigured, + Search: result.Configuration.InboundConfigured, + Threads: result.Configuration.InboundConfigured, + Delete: result.Configuration.InboundConfigured, + Move: result.Configuration.InboundConfigured, + MarkRead: result.Configuration.InboundConfigured, + LocalIndex: result.Configuration.InboundConfigured, + } + case "stub": + result.Capabilities = schema.MailCapabilities{ + List: true, + FetchRaw: true, + Search: true, + Threads: true, + Send: true, + Reply: true, + LocalIndex: true, + } + result.Configuration.InboundConfigured = true + result.Configuration.OutboundConfigured = true + default: + result.Capabilities = schema.MailCapabilities{} + } + + return result +} + +func inboundConfigured(account config.AccountConfig) bool { + switch strings.ToLower(strings.TrimSpace(account.Driver)) { + case "imap": + return strings.TrimSpace(account.Host) != "" && account.Port != 0 && strings.TrimSpace(account.Username) != "" && strings.TrimSpace(account.Password) != "" + case "dir": + return strings.TrimSpace(account.Path) != "" + case "stub": + return true + default: + return false + } +} + +func outboundConfigured(account config.AccountConfig) bool { + switch strings.ToLower(strings.TrimSpace(account.Driver)) { + case "imap": + username := firstNonEmpty(account.SMTPUsername, account.Username) + password := firstNonEmpty(account.SMTPPassword, account.Password) + return strings.TrimSpace(account.SMTPHost) != "" && account.SMTPPort != 0 && username != "" && password != "" + case "stub": + return true + default: + return false + } +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..2ca4644 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,439 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nonozone/MailCli/pkg/schema" +) + +func TestConfigInitWritesIMAPConfigWithEnvironmentSecretReferences(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "nested", "config.yaml") + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "config", "init", + "--config", configPath, + "--account", "work", + "--driver", "imap", + "--host", "imap.example.com", + "--port", "993", + "--username", "user@example.com", + "--password-env", "MAILCLI_IMAP_PASSWORD", + "--smtp-host", "smtp.example.com", + "--smtp-port", "587", + "--smtp-username", "user@example.com", + "--smtp-password-env", "MAILCLI_SMTP_PASSWORD", + "--mailbox", "INBOX", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config init to succeed: %v\n%s", err, out.String()) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("expected config file to be written: %v", err) + } + text := string(raw) + for _, want := range []string{ + "current_account: work", + "name: work", + "driver: imap", + "host: imap.example.com", + "password: ${MAILCLI_IMAP_PASSWORD}", + "smtp_password: ${MAILCLI_SMTP_PASSWORD}", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected config to contain %q, got:\n%s", want, text) + } + } + if strings.Contains(text, "super-secret") || strings.Contains(out.String(), "super-secret") { + t.Fatalf("config init must not print or write raw secrets") + } + info, err := os.Stat(configPath) + if err != nil { + t.Fatalf("expected config file stat to succeed: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected config file permissions 0600, got %o", got) + } + + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &result); err != nil { + t.Fatalf("expected JSON init result: %v\n%s", err, out.String()) + } + if result["status"] != "created" || result["account"] != "work" || result["driver"] != "imap" { + t.Fatalf("unexpected init result: %#v", result) + } +} + +func TestConfigInitRefusesToOverwriteExistingConfigWithoutForce(t *testing.T) { + configPath := writeTempFile(t, "config.yaml", "current_account: existing\n") + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "config", "init", + "--config", configPath, + "--account", "work", + "--driver", "dir", + "--path", "./mail", + }) + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected config init to refuse overwriting an existing file") + } + if !strings.Contains(err.Error(), "--force") { + t.Fatalf("expected overwrite error to mention --force, got %v", err) + } + raw, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatal(readErr) + } + if string(raw) != "current_account: existing\n" { + t.Fatalf("expected existing config to remain unchanged, got %q", string(raw)) + } +} + +func TestConfigDoctorReportsCompleteDirAccountOK(t *testing.T) { + fixtureDir := t.TempDir() + configPath := writeTempFile(t, "config.yaml", ` +current_account: fixtures +accounts: + - name: fixtures + driver: dir + path: `+fixtureDir+` + mailbox: INBOX +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "doctor", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config doctor to succeed for complete dir config: %v\n%s", err, out.String()) + } + + var got schema.ConfigDiagnostics + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON doctor result: %v\n%s", err, out.String()) + } + if got.Status != "ok" { + t.Fatalf("expected ok status, got %+v", got) + } + if len(got.Accounts) != 1 || got.Accounts[0].Status != "ok" { + t.Fatalf("expected one ok account, got %+v", got.Accounts) + } + if !got.Accounts[0].Capabilities.Capabilities.LocalIndex { + t.Fatalf("expected doctor to include account capabilities: %+v", got.Accounts[0].Capabilities) + } +} + +func TestConfigDoctorReportsIncompleteIMAPConfigWithoutLeakingSecrets(t *testing.T) { + configPath := writeTempFile(t, "config.yaml", ` +current_account: work +accounts: + - name: work + driver: imap + host: imap.example.com + port: 993 + username: user@example.com + password: super-secret + smtp_host: smtp.example.com + smtp_password: smtp-secret +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "doctor", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config doctor to return diagnostics without failing command: %v\n%s", err, out.String()) + } + if strings.Contains(out.String(), "super-secret") { + t.Fatalf("config doctor must not leak configured secrets: %s", out.String()) + } + if strings.Contains(out.String(), "smtp-secret") { + t.Fatalf("config doctor must not leak smtp secrets: %s", out.String()) + } + + var got schema.ConfigDiagnostics + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON doctor result: %v\n%s", err, out.String()) + } + if got.Status != "warning" { + t.Fatalf("expected warning status for incomplete smtp config, got %+v", got) + } + if len(got.Problems) == 0 { + t.Fatalf("expected flattened diagnostic problems") + } + if !hasDiagnosticCode(got.Problems, "smtp_port_missing") { + t.Fatalf("expected smtp missing diagnostics, got %+v", got.Problems) + } +} + +func TestConfigDoctorReportsUnsetSecretEnvironmentReferences(t *testing.T) { + t.Setenv("MAILCLI_IMAP_PASSWORD", "") + t.Setenv("MAILCLI_SMTP_PASSWORD", "") + configPath := writeTempFile(t, "config.yaml", ` +current_account: work +accounts: + - name: work + driver: imap + host: imap.example.com + port: 993 + username: user@example.com + password: ${MAILCLI_IMAP_PASSWORD} + smtp_host: smtp.example.com + smtp_port: 587 + smtp_password: ${MAILCLI_SMTP_PASSWORD} +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "doctor", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config doctor to return diagnostics without failing command: %v\n%s", err, out.String()) + } + + var got schema.ConfigDiagnostics + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON doctor result: %v\n%s", err, out.String()) + } + if got.Status != "error" { + t.Fatalf("expected error status for unset inbound env reference, got %+v", got) + } + if !hasDiagnosticCode(got.Problems, "imap_password_env_unset") { + t.Fatalf("expected imap env unset diagnostic, got %+v", got.Problems) + } + if !hasDiagnosticCode(got.Problems, "smtp_password_env_unset") { + t.Fatalf("expected smtp env unset diagnostic, got %+v", got.Problems) + } +} + +func TestConfigDoctorReportsEnvironmentBackedIMAPConfigOKWithoutLeakingSecrets(t *testing.T) { + t.Setenv("MAILCLI_IMAP_PASSWORD", "imap-secret") + t.Setenv("MAILCLI_SMTP_PASSWORD", "smtp-secret") + configPath := writeTempFile(t, "config.yaml", ` +current_account: work +accounts: + - name: work + driver: imap + host: imap.example.com + port: 993 + username: user@example.com + password: ${MAILCLI_IMAP_PASSWORD} + smtp_host: smtp.example.com + smtp_port: 587 + smtp_password: ${MAILCLI_SMTP_PASSWORD} +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "doctor", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config doctor to succeed for env-backed config: %v\n%s", err, out.String()) + } + if strings.Contains(out.String(), "imap-secret") || strings.Contains(out.String(), "smtp-secret") { + t.Fatalf("config doctor must not leak expanded secrets: %s", out.String()) + } + + var got schema.ConfigDiagnostics + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON doctor result: %v\n%s", err, out.String()) + } + if got.Status != "ok" { + t.Fatalf("expected ok status for env-backed config, got %+v", got) + } + if len(got.Accounts) != 1 || !got.Accounts[0].Capabilities.Configuration.InboundConfigured || !got.Accounts[0].Capabilities.Configuration.OutboundConfigured { + t.Fatalf("expected env-backed config to report inbound and outbound capabilities: %+v", got.Accounts) + } +} + +func TestConfigDoctorReportsMissingCurrentAccount(t *testing.T) { + configPath := writeTempFile(t, "config.yaml", ` +current_account: missing +accounts: + - name: work + driver: dir + path: ./testdata/emails + mailbox: INBOX +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "doctor", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config doctor to return diagnostics without failing command: %v\n%s", err, out.String()) + } + + var got schema.ConfigDiagnostics + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON doctor result: %v\n%s", err, out.String()) + } + if got.Status != "error" { + t.Fatalf("expected error status for missing current account, got %+v", got) + } + if !hasDiagnosticCode(got.Problems, "current_account_not_found") { + t.Fatalf("expected current account diagnostic, got %+v", got.Problems) + } +} + +func hasDiagnosticCode(items []schema.ConfigDiagnostic, code string) bool { + for _, item := range items { + if item.Code == code { + return true + } + } + return false +} + +func TestConfigCapabilitiesReportsIMAPAccountCapabilities(t *testing.T) { + configPath := writeTempFile(t, "config.yaml", ` +current_account: work +accounts: + - name: work + driver: imap + host: imap.example.com + port: 993 + username: user@example.com + password: super-secret + tls: true + mailbox: INBOX + smtp_host: smtp.example.com + smtp_port: 587 + smtp_username: user@example.com + smtp_password: smtp-secret +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "capabilities", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config capabilities to succeed: %v", err) + } + + if strings.Contains(out.String(), "super-secret") || strings.Contains(out.String(), "smtp-secret") { + t.Fatalf("capabilities output must not include configured secrets: %s", out.String()) + } + + var got schema.AccountCapabilities + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON capabilities output: %v", err) + } + + if got.Account != "work" || got.Driver != "imap" || got.Mailbox != "INBOX" { + t.Fatalf("unexpected account identity: %+v", got) + } + if !got.Capabilities.List || !got.Capabilities.FetchRaw || !got.Capabilities.Watch || !got.Capabilities.Send { + t.Fatalf("expected imap account to support list, fetch, watch, send: %+v", got.Capabilities) + } + if !got.Capabilities.Delete || !got.Capabilities.Move || !got.Capabilities.MarkRead { + t.Fatalf("expected imap account to support mailbox mutations: %+v", got.Capabilities) + } + if !got.Capabilities.LocalIndex || !got.Configuration.InboundConfigured || !got.Configuration.OutboundConfigured { + t.Fatalf("expected configured imap account to be indexable with inbound/outbound config: %+v", got) + } +} + +func TestConfigCapabilitiesReportsDirAccountWithoutOutboundOrWatch(t *testing.T) { + configPath := writeTempFile(t, "config.yaml", ` +current_account: fixtures +accounts: + - name: fixtures + driver: dir + path: ./testdata/emails + mailbox: INBOX +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "capabilities", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config capabilities to succeed: %v", err) + } + + var got schema.AccountCapabilities + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON capabilities output: %v", err) + } + + if got.Account != "fixtures" || got.Driver != "dir" { + t.Fatalf("unexpected account identity: %+v", got) + } + if !got.Capabilities.List || !got.Capabilities.FetchRaw || !got.Capabilities.LocalIndex { + t.Fatalf("expected dir account to support read and local index capabilities: %+v", got.Capabilities) + } + if got.Capabilities.Send || got.Capabilities.Watch { + t.Fatalf("expected dir account to omit send and watch capabilities: %+v", got.Capabilities) + } + if !got.Capabilities.Delete || !got.Capabilities.Move || !got.Capabilities.MarkRead { + t.Fatalf("expected dir account to report mailbox mutation support: %+v", got.Capabilities) + } + if !got.Configuration.InboundConfigured || got.Configuration.OutboundConfigured { + t.Fatalf("expected dir account to be inbound-only: %+v", got.Configuration) + } +} + +func TestConfigCapabilitiesRequiresCompleteIMAPInboundConfigForReadActions(t *testing.T) { + configPath := writeTempFile(t, "config.yaml", ` +current_account: work +accounts: + - name: work + driver: imap + username: user@example.com + mailbox: INBOX +`) + + cmd := NewRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"config", "capabilities", "--config", configPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected config capabilities to succeed: %v", err) + } + + var got schema.AccountCapabilities + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &got); err != nil { + t.Fatalf("expected JSON capabilities output: %v", err) + } + + if got.Configuration.InboundConfigured { + t.Fatalf("expected incomplete imap account to report inbound_configured=false: %+v", got.Configuration) + } + if got.Capabilities.List || got.Capabilities.FetchRaw || got.Capabilities.Watch || got.Capabilities.Delete { + t.Fatalf("expected incomplete imap inbound config to disable read and mutation capabilities: %+v", got.Capabilities) + } +} diff --git a/cmd/watch.go b/cmd/watch.go index bde541f..7ee0e14 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -66,7 +66,7 @@ IMAP accounts use IMAP IDLE (push) when available; all other drivers fall back to polling via --poll. Pipe the output to any AI agent or script: - mailcli watch --account work | python3 ai_reply_agent.py`, + mailcli watch --account work | go run ./examples/go/watch_reply_agent`, RunE: func(cmd *cobra.Command, args []string) error { // Graceful shutdown on Ctrl+C / SIGTERM. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/docs/en/agent-workflows.md b/docs/en/agent-workflows.md index ba2cdc2..f2732ab 100644 --- a/docs/en/agent-workflows.md +++ b/docs/en/agent-workflows.md @@ -336,4 +336,4 @@ For agent developers, the stable contracts should be: - `ReplyDraft` - `SendResult` -These are the boundaries that should remain easy to call from Python, shell, Node.js, or other agent runtimes. +These are the boundaries that should remain easy to call from Go, shell, Node.js, or other agent runtimes. diff --git a/docs/en/examples/README.md b/docs/en/examples/README.md index 19f3669..06c49ed 100644 --- a/docs/en/examples/README.md +++ b/docs/en/examples/README.md @@ -28,30 +28,32 @@ make demo-local-thread-check - [OpenAI External Provider](openai-external-provider.md) Best for plugging an OpenAI-backed analyzer into either agent example. -## Python Scripts +## Go Examples ### Agent Workflows -- `examples/python/agent_inbox_assistant.py` +- `examples/go/agent_inbox_assistant` Minimal single-message agent boundary. -- `examples/python/agent_thread_assistant.py` +- `examples/go/agent_thread_assistant` Thread-aware local retrieval and reply boundary. +- `examples/go/watch_reply_agent` + JSONL watch-event agent loop. ### Raw Utilities -- `examples/python/parse_email.py` +- `examples/go/parse_email` Parse one local `.eml` file through `mailcli parse`. -- `examples/python/reply_dry_run.py` +- `examples/go/reply_dry_run` Compile one `ReplyDraft` JSON file through `mailcli reply --dry-run`. -- `examples/python/refresh_local_thread_demo.py` +- `examples/go/refresh_local_thread_demo` Regenerate the stored local-thread-demo artifacts from the current fixture corpus. ### Provider Adapters -- `examples/providers/template_external_provider.py` +- `examples/go/providers/template_external_provider` Minimal external provider template for both inbox and thread payloads. -- `examples/providers/openai_external_provider.py` - Optional OpenAI-backed provider using the Responses API. +- `examples/go/providers/openai_external_provider` + Optional OpenAI-backed provider using the Responses API through Go standard-library HTTP. ## Shell Scripts diff --git a/docs/en/examples/agent-inbox-assistant.md b/docs/en/examples/agent-inbox-assistant.md index 54d9412..8a7aea6 100644 --- a/docs/en/examples/agent-inbox-assistant.md +++ b/docs/en/examples/agent-inbox-assistant.md @@ -12,7 +12,7 @@ It is intentionally simple: The example script is: -- `examples/python/agent_inbox_assistant.py` +- `examples/go/agent_inbox_assistant` ## What It Demonstrates @@ -25,7 +25,7 @@ The example script is: ## Local `.eml` Example ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/verification.eml ``` @@ -33,7 +33,7 @@ python3 examples/python/agent_inbox_assistant.py \ ## Reply Dry-Run Example ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ @@ -50,7 +50,7 @@ to the JSON output. ## Configured Inbox Example ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --config ~/.config/mailcli/config.yaml \ --account work \ @@ -60,7 +60,7 @@ python3 examples/python/agent_inbox_assistant.py \ ## Zero-Network Fixture Example ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -80,13 +80,12 @@ This uses the built-in `dir` driver to fetch a local `.eml` fixture through the You can delegate the analysis step to your own script or agent runtime: ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` The external provider receives JSON on stdin: @@ -113,5 +112,5 @@ Contract reference: - [Agent Provider Contract](../spec/agent-provider.md) - [Examples Index](README.md) -- Template provider: `examples/providers/template_external_provider.py` +- Template provider: `examples/go/providers/template_external_provider` - Optional OpenAI provider example: [OpenAI External Provider](openai-external-provider.md) diff --git a/docs/en/examples/agent-thread-assistant.md b/docs/en/examples/agent-thread-assistant.md index a8cc606..1773f0d 100644 --- a/docs/en/examples/agent-thread-assistant.md +++ b/docs/en/examples/agent-thread-assistant.md @@ -14,7 +14,7 @@ It is meant for developers who want to model the common agent loop: The example script is: -- `examples/python/agent_thread_assistant.py` +- `examples/go/agent_thread_assistant` ## What It Demonstrates @@ -28,7 +28,7 @@ The example script is: ## Inbox-Backed Example ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config ~/.config/mailcli/config.yaml \ --account work \ @@ -41,7 +41,7 @@ python3 examples/python/agent_thread_assistant.py \ ## Zero-Network Fixture Example ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -50,12 +50,12 @@ python3 examples/python/agent_thread_assistant.py \ --query invoice ``` -This uses the built-in `dir` driver to run the normal `sync -> threads -> thread` loop against the repository fixture corpus. Here `--sync-limit 0` is the Python example's sync setting, and it tells the script to call `mailcli sync --limit 0` instead of truncating the initial sync pass. +This uses the built-in `dir` driver to run the normal `sync -> threads -> thread` loop against the repository fixture corpus. Here `--sync-limit 0` tells the Go example to call `mailcli sync --limit 0` instead of truncating the initial sync pass. ## Existing Local Index Example ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ @@ -69,15 +69,14 @@ python3 examples/python/agent_thread_assistant.py \ You can also delegate the thread analysis step to your own script or agent runtime: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ --thread-id "" \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` The external provider receives a JSON payload that includes: @@ -119,7 +118,7 @@ This keeps the example usable even when developers only have an existing local i ## Output Shape -The script prints one JSON report that includes: +The Go example prints one JSON report that includes: - `sync` - `selection` diff --git a/docs/en/examples/local-thread-demo.md b/docs/en/examples/local-thread-demo.md index be2b2af..2df57b1 100644 --- a/docs/en/examples/local-thread-demo.md +++ b/docs/en/examples/local-thread-demo.md @@ -2,7 +2,7 @@ # Local Thread Demo -This page shows the full agent round trip for a local thread workflow without reading the Python example source. +This page shows the full agent round trip for a local thread workflow without reading the Go example source. It uses: @@ -36,10 +36,10 @@ To verify that the checked-in artifacts are still current: make demo-local-thread-check ``` -The underlying script remains available when you need explicit arguments: +The underlying Go command remains available when you need explicit arguments: ```bash -python3 examples/python/refresh_local_thread_demo.py \ +go run ./examples/go/refresh_local_thread_demo \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -187,7 +187,7 @@ If you want to inspect the whole loop in one object, see: - [agent-report.json](../../../examples/artifacts/local-thread-demo/agent-report.json) -That file mirrors the output of `examples/python/agent_thread_assistant.py` for this local fixture flow. +That file mirrors the output of `examples/go/agent_thread_assistant` for this local fixture flow. ## Related diff --git a/docs/en/examples/openai-external-provider.md b/docs/en/examples/openai-external-provider.md index 86d6ec7..4e3dcce 100644 --- a/docs/en/examples/openai-external-provider.md +++ b/docs/en/examples/openai-external-provider.md @@ -9,16 +9,11 @@ It is optional on purpose: - the main repository does not depend on the OpenAI SDK - the provider is a standalone subprocess example - the contract still flows through stdin and stdout JSON +- it is implemented in Go with standard-library HTTP Example file: -- `examples/providers/openai_external_provider.py` - -## Install - -```bash -pip install openai -``` +- `examples/go/providers/openai_external_provider` ## Environment @@ -32,27 +27,29 @@ export OPENAI_MODEL=gpt-5-mini ## Run With The Agent Example ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg examples/providers/openai_external_provider.py + --provider-command go \ + --provider-arg run \ + --provider-arg ./examples/go/providers/openai_external_provider ``` The same provider can also be used with the thread-aware example: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ --thread-id "" \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg examples/providers/openai_external_provider.py + --provider-command go \ + --provider-arg run \ + --provider-arg ./examples/go/providers/openai_external_provider ``` ## What It Does diff --git a/docs/en/project/github-backlog.md b/docs/en/project/github-backlog.md index 3583328..2a7638b 100644 --- a/docs/en/project/github-backlog.md +++ b/docs/en/project/github-backlog.md @@ -11,10 +11,11 @@ Use it when you want to create milestones, labels, and first-wave issues without ### Milestones - `v0.1 hardening` -- `parser quality` -- `local memory` -- `contributor surface` -- `provider expansion` +- `go-only core` +- `existing mailbox setup` +- `inbox intelligence` +- `parser actions and attachments` +- `safe outbound automation` ### Labels @@ -29,9 +30,9 @@ Use it when you want to create milestones, labels, and first-wave issues without - `good first issue` - `rfc` -## Recommended First Wave +## Historical Issue Draft Status -Create these first: +The following 8 items came from the previous roadmap. Most are now complete or should only be kept as follow-up expansion drafts: 1. Align docs with actual RC capabilities 2. Add JSON contract snapshot tests for CLI commands @@ -51,15 +52,19 @@ Status note: - item 8 is now covered by `docs/en/contributing/parser.md` - keep those drafts only if follow-up expansion work is needed -If starting from the current repository state, the higher-value open items are now: +If starting from the current repository state, the current first wave should be: -1. Strengthen HTML body extraction and noise filtering -2. Improve URL normalization for agent-facing actions -3. Expand parser corpus for real-world edge cases -4. Add one more built-in provider only after the shared layers stay stable +1. Tighten existing-mailbox setup, configuration, account capabilities, and Go-first examples +2. Improve inbox/thread summaries, priority, and todo extraction +3. Promote inbound attachments and invoice entry points to first-class structured output +4. Add prepare / confirm flow and local operation logs for dangerous actions + +Provider expansion and dedicated Agent mailbox work are not part of the current first wave. ## Issue Drafts +The drafts below mostly preserve issue wording from the previous roadmap where it is still useful. New Go-mainline issues can be split from the four current priority items above. + ## Issue 1 Title: `Align docs with actual RC capabilities` diff --git a/docs/en/project/internal-priority.md b/docs/en/project/internal-priority.md index 629886e..04efba8 100644 --- a/docs/en/project/internal-priority.md +++ b/docs/en/project/internal-priority.md @@ -26,108 +26,98 @@ The original maintainer-led "core five" hardening sequence is now complete on `m The follow-up maintenance loop for the stored local thread demo artifacts is also now wired into repository-level commands and CI checks. -That means the next maintainer phase should move from boundary hardening toward corpus strength, contributor leverage, and shared quality bars. +That means the next maintainer phase should move from boundary hardening toward a Go-only core, existing-mailbox UX, inbox intelligence, and safe automation loops. ## Working Assumption For the next stage, treat MailCLI as: -- maintainer-led in core contracts -- maintainer-led in parser quality -- maintainer-led in local memory model -- community-assisted in docs, fixtures, examples, and smaller contributor-surface tasks +- official executable behavior belongs in Go core +- maintainers own core contracts, parser quality, and the local memory model +- AI providers stay outside core through language-neutral JSON contracts +- Go examples are the official path; do not maintain a second official runtime path +- docs, fixtures, examples, and smaller contributor-surface tasks can be community-assisted -## The Next Maintainer Five +## The Next Maintainer Four -These are the next five tasks that should be treated as the main internal development sequence. +These are the next four tasks that should be treated as the main internal development sequence. The fifth track, dedicated Agent mailboxes or broad provider expansion, is deferred for now. -### 1. Expand the parser fixture corpus around real failure modes +### 1. Existing mailbox setup and configuration: Go core Why first: -- parser quality is now bottlenecked more by corpus coverage than by missing infrastructure -- more fixtures will make the current heuristics safer to iterate on -- this is the fastest way to keep improving agent usefulness without redefining contracts +- most users already have Gmail, Outlook, QQ, 163, or corporate mailboxes; they will not start with a dedicated Agent mailbox +- the real need is helping AI safely understand and process existing mailboxes +- install, configuration, connection tests, and account-capability output should come from the Go binary, without another language runtime +- the first implementation slice should make `config init`, `config doctor`, `config test`, and `config capabilities` cover setup, static diagnostics, live checks, and machine-readable capability discovery Why maintainers should own it: -- fixture choice defines the real parser quality bar -- the first wave of corpus curation should reflect maintainer taste and product priorities +- this defines MailCLI's first experience for normal users +- config contracts and account-capability output affect every later agent workflow -## 2. Add a fake-driver or conformance harness for contributor drivers +## 2. Inbox/thread summaries, priority, and todo extraction Why second: -- driver contribution is still harder than it needs to be -- a shared acceptance bar will lower maintainer review cost -- this opens a safer path for outside provider contributions +- users want to know what matters, what needs a reply, and what should happen next +- local thread/search is already usable; the next step is explainable triage signals +- Go should provide structured candidate data, while LLM providers remain external for interpretation and recommendations Why maintainers should own it: -- it defines the minimum transport contract the ecosystem will inherit -- a weak harness would create noisy provider PRs later +- summaries and priority signals affect public JSON shape and prompt usage +- weak local memory semantics will leak into examples and user workflows quickly -## 3. Tighten local search semantics and ranking +## 3. Attachment, invoice, code, and action-link extraction Why third: -- local memory is now a real user-facing loop -- better ranking and filtering can reduce unnecessary full-message loads -- this improves agent usefulness without introducing new transport surface area +- these are the most common and automation-worthy signals in ordinary inboxes +- the existing action/code baseline is already in place; follow-up work should cover inbound attachments and invoice entry points +- this improves AI usefulness for existing mailboxes more directly than adding a new provider Why maintainers should own it: -- search semantics are close to the long-term memory model -- weak ranking choices would leak into prompts and examples quickly +- parser/schema field design defines the product quality bar +- fixture choice and golden output should reflect maintainer judgment about real mail scenarios -## 4. Improve outbound ergonomics without changing the core contract +## 4. Draft, confirmation, execution, and operation logging Why fourth: -- send and reply already work, but contributor and user ergonomics can still improve -- this keeps the project useful while larger provider work is deferred -- it strengthens the read/write loop for agents end to end +- automation must be controllable, especially for high-impact actions such as send, delete, move, and mark +- `send`, `reply`, and mailbox mutations already work; the next step is prepare / confirm / log +- this moves agents from "can call commands" to "can execute within an auditable boundary" Why maintainers should own it: -- send/reply ergonomics are close to stable public contracts -- maintainers should decide where convenience ends and schema sprawl begins - -## 5. Add one more built-in provider only after the previous four are in shape - -Why fifth: - -- ecosystem breadth matters, but only once the shared layers are easier to extend safely -- another provider will strengthen the architecture story if it does not destabilize the core -- it is easier to review once parser, local memory, and driver guidance are stronger - -Why maintainers should own it: - -- the second real provider will shape community expectations for extension style -- maintainers still need to set the baseline for transport isolation and docs quality +- it is close to the public CLI contract and the user trust boundary +- confirmation tokens, intent ids, and operation log fields need to be stable from the start ## Recommended Sequence -1. Expand fixture coverage for parser regressions. -2. Build driver conformance tooling. -3. Improve local search semantics. -4. Improve outbound ergonomics. -5. Add one more provider. +1. Tighten existing-mailbox setup, configuration, account capabilities, and Go-first examples. +2. Improve inbox/thread summaries, priority, and todo extraction. +3. Improve attachment, invoice, code, and action-link extraction. +4. Add the draft / confirm / execute / operation-log loop. ## What Can Wait -These are useful, but should not displace the next maintainer five: +These are useful, but should not displace the next maintainer four: - docs alignment cleanup - parser contributor guide - broad provider expansion - broader community process work +- dedicated Agent mailbox or hosted mailbox identity +- maintaining a second official runtime path beside Go They matter, but they are support work around the product core, not the main path to a stronger `v0.2`. ## Community-Suitable Parallel Work -While maintainers focus on the core five, the community can still help with: +While maintainers focus on the core four, the community can still help with: - fixture collection and anonymization - examples @@ -135,6 +125,7 @@ While maintainers focus on the core five, the community can still help with: - small parser regression reports - contributor guides - test-case additions that do not redefine contracts +- unofficial language examples, as long as they do not become installation or usage prerequisites ## Rule Of Thumb @@ -144,6 +135,7 @@ If a task changes: - parser quality bar - thread summary semantics - local memory semantics +- Go CLI execution / confirmation / audit contracts it should be maintainer-driven first. diff --git a/docs/en/project/next-roadmap.md b/docs/en/project/next-roadmap.md index e210ad3..42c9ebc 100644 --- a/docs/en/project/next-roadmap.md +++ b/docs/en/project/next-roadmap.md @@ -8,6 +8,8 @@ The goal is not to add random surface area. The goal is to turn the current RC into a stable, contributor-friendly open-source project for agent developers. +The next phase also clarifies a product and implementation direction: **the core runtime and official examples should be Go-only**. MailCLI should ship as a binary that agents can call reliably and users can install without understanding or deploying another language runtime. + ## Recently Completed On `main` The original RC hardening pass already shipped several important pieces: @@ -29,19 +31,35 @@ For the realistic maintainer-led sequence, see [Internal Development Priority](i ## Priority Order -1. Harden the current RC boundary. -2. Improve parser quality where agents feel pain today. -3. Make local memory and thread workflows more reliable. -4. Reduce contribution overhead for new driver and parser contributors. -5. Expand providers only after the shared contracts are clearer. +1. Tighten existing-mailbox setup and configuration as Go core behavior. +2. Make inbox/thread summaries, priority, and todo extraction structured Go command output. +3. Improve attachment, invoice, code, and action-link extraction in the Go parser/schema. +4. Make draft, confirmation, execution, and operation logging a Go CLI contract. +5. Do not prioritize dedicated Agent mailbox hosting or broad provider expansion yet. ## Maintainer Rules - prefer stable machine-facing contracts over feature count +- keep official executable behavior in Go; do not introduce non-Go runtime prerequisites for installation or agent use +- keep AI provider integration language-neutral through JSON contracts, while keeping official examples and long-term maintenance Go-first - keep provider-specific business logic out of shared parser and composer layers - treat parser heuristics as product work, not cleanup work - optimize for "easy to contribute to" as much as "useful to use" +## Next Go Mainline + +These are the four main tracks after the current product direction decision: + +1. **Existing mailbox setup and configuration: Go core.** + Help users connect the Gmail, Outlook, QQ, 163, corporate mailbox, or local `.eml` data they already have, instead of requiring a dedicated Agent mailbox first. + The first concrete slice is a Go-only config path: `config init`, `config doctor`, `config test`, and `config capabilities`. +2. **Inbox/thread summaries, priority, and todo extraction: Go provides structured data and commands; AI providers remain external.** + Go owns stable retrieval, aggregation, fields, and lightweight signals. LLMs can interpret, summarize, and draft recommendations outside the core. +3. **Attachment, invoice, code, and action-link extraction: Go parser/schema.** + These are high-value signals in real inboxes and should be part of the core parser quality bar. +4. **Draft, confirmation, execution, and operation logging: Go CLI contract.** + Automation must be controllable. High-impact actions should produce auditable intents before execution and machine-readable results afterward. + ## Milestone 1: v0.1 Hardening Goal: make the current RC easier to trust, easier to document, and safer to build against. @@ -91,7 +109,7 @@ Status: completed. - point driver and schema docs to that path - Deliverable: `.github` template and docs update -## Milestone 2: Parser Quality +## Milestone 2: Parser / Schema Quality Goal: improve the one area that most directly affects agent usefulness. @@ -99,6 +117,7 @@ Goal: improve the one area that most directly affects agent usefulness. - HTML body extraction is more reliable on noisy templates - redirect-heavy tracking links are cleaned more aggressively +- structured output for attachments, invoices, codes, and action links is more complete - fixture coverage better represents real agent workflows - parser regressions are easier to catch before release @@ -138,15 +157,26 @@ Status: baseline improvement completed; follow-up work should focus on more fixt - document what each fixture is meant to protect - Deliverable: new fixtures and focused regression coverage -## Milestone 3: Local Memory And Threads +#### Issue: Promote inbound attachments and invoice entry points to first-class structured output + +- Area: parser, schema, cmd +- Problem: high-value inbox information often lives in attachments, invoice entry points, download links, or body URLs; agents should not rely on full-text guessing. +- Scope: + - design a stable representation for inbound attachments and attachment entry points in `StandardMessage` + - distinguish real MIME attachments, body download links, and invoice view/download actions + - add fixtures and golden coverage for invoices, attachment notices, and multilingual codes +- Deliverable: schema/parser changes, CLI output, tests, and spec updates + +## Milestone 3: Inbox / Thread Intelligence -Goal: make local agent retrieval feel dependable instead of experimental. +Goal: help AI retrieve information from a user's existing mailbox faster and more accurately, not just run keyword search. ### Done when - sync behavior is easier to reason about across reruns - cache/index state is more visible to users and contributors - thread metadata reduces unnecessary full-message loads +- inbox/thread summaries support common triage decisions such as priority, todos, and needs-reply ### Suggested GitHub issues @@ -174,79 +204,70 @@ Status: baseline thread-summary expansion and local ranking refinement completed - snapshot-test the chosen shape - Deliverable: schema/output adjustment with docs -## Milestone 4: Contributor Surface +#### Issue: Add priority and todo extraction signals for inbox/thread triage + +- Area: internal/index, cmd, schema +- Problem: the common user question is not just "find mail"; it is "what matters, what needs handling, and what should happen next." +- Scope: + - design lightweight, explainable priority / needs_reply / todo-like signals + - keep Go output as structured candidate signals without binding core to a specific LLM + - add fixed output coverage to local fixtures and the thread demo +- Deliverable: Go command output, schema/spec updates, and snapshot tests + +## Milestone 4: Safe Outbound Loop And Operation Logs -Goal: lower the effort required for outside developers to make useful PRs. +Goal: move AI automation from "execute the command directly" to "prepare intent, confirm execution, record result." ### Done when -- adding a driver is easy to understand and test -- parser contributors can run targeted tests from documented entry points -- contract changes have a review process that does not depend on tribal knowledge +- high-impact actions such as `send`, `reply`, `delete`, `move`, and `mark` can produce a dry-run or intent first +- confirmation uses a stable token or intent id so agents do not accidentally execute a different action +- execution results and failure reasons are written to machine-readable operation logs +- operation logging does not depend on provider-specific behavior ### Suggested GitHub issues -#### Issue: Add a fake-driver test harness for extension contributors - -Status: baseline reusable driver contract harness completed in `pkg/driver/drivertest`. +#### Issue: Add prepare / confirm flow for dangerous actions -- Area: driver, tests, docs -- Problem: contributor drivers are harder to validate than they need to be +- Area: cmd, schema, docs +- Problem: agents can draft send/delete/move actions, but direct execution amplifies mistakes. - Scope: - - add reusable test helpers or fixtures for driver conformance - - make list/fetch/send expectations explicit - - document the minimum acceptance bar -- Deliverable: reusable test harness plus contributor docs + - design `prepare` output for sending and mailbox mutations + - execute the same intent through an intent id or confirmation token + - keep dry-run, prepare, and confirm output readable by agents +- Deliverable: Go CLI contract, schema, tests, and docs -#### Issue: Write a parser contributor guide +#### Issue: Add local operation logs -- Area: docs -- Problem: parser work is high-value, but the entry path is still implicit +- Area: cmd, internal, docs +- Problem: agent automation needs auditability: what ran, why it failed, and which message or thread it targeted. - Scope: - - explain fixture layout, golden tests, and parser design constraints - - explain where heuristic behavior is acceptable and where it is not - - link to the most relevant parser packages and tests -- Deliverable: new contributor doc - -Status: completed at `docs/en/contributing/parser.md`. - -## Milestone 5: Provider Expansion + - record operation type, account, target ID, intent id, result, error code, and timestamp + - provide `mailcli operations list/show` or equivalent query commands + - avoid storing secret fields and full sensitive bodies +- Deliverable: Go storage/CLI, tests, and security notes -Goal: grow the ecosystem without destabilizing the shared model. +## Deferred: Provider Expansion And Dedicated Agent Mailboxes -This milestone should start only after the previous milestones are in reasonable shape. +Tencent Agent Mail shows that a dedicated Agent mailbox identity can be valuable, but MailCLI's current mainline is not hosted mailbox service. The next phase should first help users process their existing mailboxes with AI. -### Suggested GitHub issues - -#### Issue: Add one more built-in provider with full docs and tests +Therefore defer: -- Area: driver, docs -- Problem: the ecosystem story gets stronger once there is more than one real integration path -- Scope: - - implement one additional provider or provider style - - keep transport isolated from parser and composer logic - - document config, limits, and test strategy -- Deliverable: new driver, tests, docs - -#### Issue: Define a driver compliance checklist - -Status: baseline checklist is completed in the current driver spec and contributor docs. +- OAuth-heavy auth flows in core +- hosted `@agent` mailbox identity +- broad provider expansion +- runtime plugin loading -- Area: docs, tests -- Problem: community drivers need a shared quality bar -- Scope: - - define required behaviors for list, fetch, send, and config validation - - map those expectations to tests and contributor guidance - - keep the checklist stable enough to reference in PR review -- Deliverable: spec or contributing doc update +Re-evaluate dedicated Agent mailbox modes or new providers only after the Go core setup, retrieval, extraction, and safe execution loop are stable. ## Recommended Milestones In GitHub - `v0.1 hardening` -- `parser quality` -- `local memory` -- `contributor surface` -- `provider expansion` +- `go-only core` +- `existing mailbox setup` +- `inbox intelligence` +- `parser actions and attachments` +- `safe outbound automation` ## Recommended Labels @@ -265,8 +286,10 @@ Status: baseline checklist is completed in the current driver spec and contribut - full terminal mail client UX - OAuth-heavy auth flows in core +- hosted dedicated Agent mailbox service - runtime plugin loading - provider-specific business policy in shared layers - trying to solve every mailbox vendor at once +- adding a second official runtime path beside Go -The strongest next move is to make the current agent boundary sharper, not broader. +The strongest next move is to use Go to make the boundary for "AI safely processes a user's existing mailbox" sharper, not broader. diff --git a/docs/en/release/announcement-kit.md b/docs/en/release/announcement-kit.md index c914737..c5e400b 100644 --- a/docs/en/release/announcement-kit.md +++ b/docs/en/release/announcement-kit.md @@ -61,7 +61,7 @@ Highlights in the current RC: - Improve parser output with stronger HTML body extraction and cleaner tracked URL normalization - Compile outbound drafts and replies through `DraftMessage` and `ReplyDraft` - Support external provider workflows for both single-message and thread-aware agent examples -- Ship Python, shell, template-provider, and optional OpenAI-provider examples +- Ship Go, shell, template-provider, and optional OpenAI-provider examples - Ship a full local round-trip demo and ready-to-run fixture config for first-time users Stable contracts for integrators: diff --git a/docs/en/release/github-v0.1.0-rc1.md b/docs/en/release/github-v0.1.0-rc1.md index 237ff69..2d0e671 100644 --- a/docs/en/release/github-v0.1.0-rc1.md +++ b/docs/en/release/github-v0.1.0-rc1.md @@ -18,7 +18,7 @@ First release candidate for MailCLI as an open, AI-native email interface. - `DraftMessage` - `ReplyDraft` - `SendResult` -- Ship Python and shell examples, including an external provider contract and an optional OpenAI provider example +- Ship Go and shell examples, including an external provider contract and an optional OpenAI provider example - Ship a ready-to-run fixture config and a full local thread demo for first-time users ## Included In This RC @@ -76,7 +76,8 @@ For this RC, the intended stable boundary is: - `go test ./...` - `go build ./cmd/mailcli` -- `python3 -m py_compile examples/python/*.py examples/providers/*.py` +- `go test ./examples` +- `make demo-local-thread-check` ## Fastest First Run diff --git a/docs/en/release/v0.1-rc.md b/docs/en/release/v0.1-rc.md index 556841c..1eca540 100644 --- a/docs/en/release/v0.1-rc.md +++ b/docs/en/release/v0.1-rc.md @@ -14,7 +14,7 @@ The project now has a usable end-to-end loop for: - syncing recent messages into a local index and searching them locally - inspecting thread summaries and full local threads for agent triage - compiling and sending outbound drafts and replies -- integrating with Python, shell, and external agent providers +- integrating with Go, shell, and external agent providers ## What Is In Scope @@ -71,7 +71,7 @@ If you want the shortest path from clone to value, start without IMAP: go build -o mailcli ./cmd/mailcli ./mailcli sync --config examples/config/fixtures-dir.yaml --account fixtures --index /tmp/mailcli-fixtures-index.json --limit 20 ./mailcli threads --index /tmp/mailcli-fixtures-index.json invoice -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ diff --git a/docs/en/release/v0.1.0.md b/docs/en/release/v0.1.0.md index d9eab19..fe4ddef 100644 --- a/docs/en/release/v0.1.0.md +++ b/docs/en/release/v0.1.0.md @@ -49,7 +49,7 @@ Ready-to-use JSON schemas for LLM function calling: - `tools/openai.json` — OpenAI Function Calling format (12 tools) - `tools/anthropic.json` — Anthropic Tool Use format (12 tools) -- `tools/agent_example.py` — Reference `watch → LLM → reply` pipeline +- `examples/go/watch_reply_agent` — Reference `watch → agent → reply` pipeline - `tools/README.md` — Integration guide with `tool_to_cmd` dispatch pattern ### Parser Improvements @@ -62,9 +62,9 @@ Ready-to-use JSON schemas for LLM function calling: ### Examples & Fixtures -- `examples/python/agent_thread_assistant.py` — Thread-mode AI agent with external provider support -- `examples/python/agent_inbox_assistant.py` — Inbox-mode AI agent -- `examples/providers/` — Template and OpenAI external provider implementations +- `examples/go/agent_thread_assistant` — Thread-mode AI agent with external provider support +- `examples/go/agent_inbox_assistant` — Inbox-mode AI agent +- `examples/go/providers/` — Template and OpenAI external provider implementations - Complete local thread demo artifacts under `examples/artifacts/local-thread-demo/` --- @@ -112,7 +112,7 @@ go build -o mailcli ./cmd/mailcli # Watch + AI agent mailcli watch --account work --index ~/.config/mailcli/index.db --auto-sync \ - | python3 tools/agent_example.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` Full documentation: [docs/en/](../../en/) diff --git a/docs/en/spec/agent-provider.md b/docs/en/spec/agent-provider.md index 59aba2d..9fb6800 100644 --- a/docs/en/spec/agent-provider.md +++ b/docs/en/spec/agent-provider.md @@ -4,7 +4,7 @@ ## Purpose -The Python inbox and thread agent examples support a pluggable external provider mode. +The Go inbox and thread agent examples support a pluggable external provider mode. This keeps the repository free of LLM SDK dependencies while still giving developers a stable handoff point for OpenAI, Claude, local models, or custom agent runtimes. @@ -13,13 +13,12 @@ This keeps the repository free of LLM SDK dependencies while still giving develo Example invocation: ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` The external provider is executed as a subprocess. @@ -27,15 +26,14 @@ The external provider is executed as a subprocess. The same contract is also used by: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ --thread-id "" \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` ## Input Contract diff --git a/docs/en/spec/config.md b/docs/en/spec/config.md index ddb05a6..844cf86 100644 --- a/docs/en/spec/config.md +++ b/docs/en/spec/config.md @@ -16,6 +16,28 @@ Default path: ~/.config/mailcli/config.yaml ``` +## Config Creation + +Use `config init` to create a starter config from the Go binary: + +```bash +mailcli config init \ + --config ~/.config/mailcli/config.yaml \ + --account work \ + --driver imap \ + --host imap.example.com \ + --port 993 \ + --username you@example.com \ + --password-env MAILCLI_IMAP_PASSWORD \ + --smtp-host smtp.example.com \ + --smtp-port 587 \ + --smtp-password-env MAILCLI_SMTP_PASSWORD +``` + +`config init` writes secret fields as environment references such as `${MAILCLI_IMAP_PASSWORD}` and `${MAILCLI_SMTP_PASSWORD}`. It does not ask for, write, or print raw password values. + +The command refuses to overwrite an existing config unless `--force` is passed. Config files created by this command are written with `0600` permissions. + ## Example ```yaml @@ -119,6 +141,101 @@ Non-secret fields are not expanded. - inject secrets with environment variables - do not commit real account secrets +## Config Diagnostics + +Agents and setup scripts can run local diagnostics without connecting to IMAP or SMTP: + +```bash +mailcli config doctor --config ~/.config/mailcli/config.yaml +``` + +The command returns JSON with a top-level `status` of `ok`, `warning`, or `error`, per-account checks, account capabilities, and a flattened `problems` list when warnings or errors are present. + +Secret checks use both raw and resolved config. If `password: ${MAILCLI_IMAP_PASSWORD}` is present but the environment variable is not set, `config doctor` reports `imap_password_env_unset` instead of printing or storing the secret value. + +Abbreviated output shape: + +```json +{ + "config_path": "/Users/you/.config/mailcli/config.yaml", + "status": "warning", + "accounts": [ + { + "name": "work", + "driver": "imap", + "status": "warning", + "capabilities": { + "account": "work", + "driver": "imap", + "mailbox": "INBOX" + }, + "checks": [ + { + "status": "warning", + "code": "smtp_port_missing", + "message": "SMTP port must be greater than 0", + "field": "smtp_port" + } + ] + } + ], + "problems": [ + { + "status": "warning", + "code": "smtp_port_missing", + "message": "SMTP port must be greater than 0", + "field": "smtp_port" + } + ] +} +``` + +`config doctor` never prints configured `password` or `smtp_password` values. Use `mailcli config test` when you need a live connection check. + +## Capability Discovery + +Agents can inspect machine-readable capabilities for the selected account: + +```bash +mailcli config capabilities --config ~/.config/mailcli/config.yaml --account work +``` + +This command reads only local config and known built-in driver behavior. It does not connect to IMAP / SMTP, and it does not print `password` or `smtp_password`. + +Example output: + +```json +{ + "account": "work", + "driver": "imap", + "mailbox": "INBOX", + "capabilities": { + "list": true, + "fetch_raw": true, + "search": true, + "threads": true, + "watch": true, + "send": true, + "reply": true, + "delete": true, + "move": true, + "mark_read": true, + "local_index": true + }, + "configuration": { + "inbound_configured": true, + "outbound_configured": true, + "uses_local_storage": false + } +} +``` + +Use this for: + +- checking whether account setup is complete during onboarding +- letting agents inspect support before calling `send`, `watch`, `delete`, or similar commands +- establishing the account boundary for later prepare / confirm / operation-log workflows + ## Explicit Non-Goals For v0.1 RC - no built-in OAuth flow diff --git a/docs/en/spec/outbound-message.md b/docs/en/spec/outbound-message.md index de46d30..e838e1f 100644 --- a/docs/en/spec/outbound-message.md +++ b/docs/en/spec/outbound-message.md @@ -165,7 +165,7 @@ cat draft.json | mailcli send - cat reply.json | mailcli reply - ``` -This keeps the contract language-agnostic and works well for agents, shell scripts, Python, and Node.js. +This keeps the contract language-agnostic and works well for agents, shell scripts, Go, Node.js, and other runtimes. ## Current Status diff --git a/docs/en/spec/watch.md b/docs/en/spec/watch.md index ff70fe8..a125bae 100644 --- a/docs/en/spec/watch.md +++ b/docs/en/spec/watch.md @@ -150,7 +150,7 @@ mailcli watch \ --account work \ --index ~/.config/mailcli/index.db \ --auto-sync \ - | python3 my_agent.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` ## Agent Integration Pattern @@ -160,7 +160,7 @@ The simplest agent pattern: ```bash mailcli watch --account work --index ~/.config/mailcli/index.db \ | while IFS= read -r line; do - echo "$line" | python3 handle_event.py + echo "$line" | ./handle_event done ``` @@ -168,14 +168,14 @@ Or pipe directly to a long-running agent process: ```bash mailcli watch --account work --index ~/.config/mailcli/index.db \ - | python3 tools/agent_example.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` The agent reads from stdin line by line. Each line is a complete JSON event. The agent can filter by `event` field and act only on `new_message`. -See [tools/agent_example.py](../../../tools/agent_example.py) for a full -reference implementation. +See [watch_reply_agent](../../../examples/go/watch_reply_agent/main.go) for a full +Go reference implementation. ## Multi-Mailbox diff --git a/docs/superpowers/plans/2026-03-26-agent-inbox-example.md b/docs/superpowers/plans/2026-03-26-agent-inbox-example.md index 534a3d8..2fe7705 100644 --- a/docs/superpowers/plans/2026-03-26-agent-inbox-example.md +++ b/docs/superpowers/plans/2026-03-26-agent-inbox-example.md @@ -4,9 +4,9 @@ **Goal:** Add a complete, minimal agent example that shows how an agent can call `mailcli` to read mail, reason over structured output, and generate a reply dry-run. -**Architecture:** Build one Python example script as the primary integration surface. It should support two input modes, local `.eml` via `mailcli parse` and configured inbox messages via `mailcli get`, then produce a JSON agent report with analysis, extracted codes/actions, and an optional reply dry-run block. +**Architecture:** Build one Go example command as the primary integration surface. It should support two input modes, local `.eml` via `mailcli parse` and configured inbox messages via `mailcli get`, then produce a JSON agent report with analysis, extracted codes/actions, and an optional reply dry-run block. -**Tech Stack:** Python 3 standard library, existing `mailcli` CLI, Go integration tests +**Tech Stack:** Go standard library, existing `mailcli` CLI, Go integration tests --- @@ -26,10 +26,10 @@ ## Chunk 2: Example Implementation -### Task 2: Implement the Python agent example +### Task 2: Implement the Go agent example **Files:** -- Create: `examples/python/agent_inbox_assistant.py` +- Create: `examples/go/agent_inbox_assistant/main.go` - [x] **Step 1: Add input selection for `--email` and `--message-id`** - [x] **Step 2: Call `mailcli parse` or `mailcli get` and parse JSON output** diff --git a/docs/superpowers/plans/2026-03-26-v0.1-rc.md b/docs/superpowers/plans/2026-03-26-v0.1-rc.md index b430bf7..3d25ed9 100644 --- a/docs/superpowers/plans/2026-03-26-v0.1-rc.md +++ b/docs/superpowers/plans/2026-03-26-v0.1-rc.md @@ -6,7 +6,7 @@ **Architecture:** Keep the existing layered split intact: `cmd` for contracts and orchestration, `driver` for transport, `parser` for normalization, and `composer` for outbound MIME. This phase is not about adding broad new surface area; it is about turning the current MVP into a stable, contributor-ready release candidate. -**Tech Stack:** Go, Cobra, enmime, go-imap, stdlib SMTP, YAML config, Python examples, GitHub docs. +**Tech Stack:** Go, Cobra, enmime, go-imap, stdlib SMTP, YAML config, Go examples, GitHub docs. --- @@ -116,7 +116,7 @@ - Create: `.github/workflows/test.yml` - [ ] Step 1: Add CI for Go tests on push and pull request -- [ ] Step 2: Include Python syntax check for example scripts +- [ ] Step 2: Include Go example and demo-artifact checks - [ ] Step 3: Keep CI lightweight and deterministic - [ ] Step 4: Commit with `ci: add test workflow` @@ -298,7 +298,8 @@ - [ ] `go test ./...` - [ ] `go build ./cmd/mailcli` -- [ ] `python3 -m py_compile examples/python/*.py examples/providers/*.py` +- [ ] `go test ./examples` +- [ ] `make demo-local-thread-check` - [ ] clean working tree - [ ] changelog or release notes draft - [ ] README and docs reflect actual shipped behavior diff --git a/docs/superpowers/specs/2026-03-26-mailcli-positioning-design.md b/docs/superpowers/specs/2026-03-26-mailcli-positioning-design.md index d824565..b296183 100644 --- a/docs/superpowers/specs/2026-03-26-mailcli-positioning-design.md +++ b/docs/superpowers/specs/2026-03-26-mailcli-positioning-design.md @@ -144,7 +144,7 @@ The project should explicitly avoid scope drift. - Provider and driver interfaces - Baseline IMAP and SMTP support - Test corpus and golden outputs -- Examples for shell, Python, and agent integrations +- Examples for Go, shell, and agent integrations - Governance and contribution documentation - 标准 JSON schema @@ -153,7 +153,7 @@ The project should explicitly avoid scope drift. - Provider 与 driver 接口 - 基础 IMAP / SMTP 支持 - 测试语料与 golden outputs -- Shell、Python、agent 集成示例 +- Go、Shell、agent 集成示例 - 治理与贡献文档 ### Guiding rule / 边界原则 @@ -411,7 +411,7 @@ An open-source documentation site can be added later and maintained by the commu html-cleaning.md actions.md examples/ - python.md + go.md shell.md agent.md roadmap.md @@ -427,7 +427,7 @@ An open-source documentation site can be added later and maintained by the commu html-cleaning.md actions.md examples/ - python.md + go.md shell.md agent.md roadmap.md diff --git a/docs/zh-CN/agent-workflows.md b/docs/zh-CN/agent-workflows.md index 280fca2..12b24a9 100644 --- a/docs/zh-CN/agent-workflows.md +++ b/docs/zh-CN/agent-workflows.md @@ -336,4 +336,4 @@ agent 生成 DraftMessage -> mailcli send - `ReplyDraft` - `SendResult` -这些边界应该始终保持容易被 Python、shell、Node.js 和其他 agent runtime 调用。 +这些边界应该始终保持容易被 Go、shell、Node.js 和其他 agent runtime 调用。 diff --git a/docs/zh-CN/examples/README.md b/docs/zh-CN/examples/README.md index cff97a5..a0cc0f9 100644 --- a/docs/zh-CN/examples/README.md +++ b/docs/zh-CN/examples/README.md @@ -28,30 +28,32 @@ make demo-local-thread-check - [OpenAI External Provider](openai-external-provider.md) 适合把 OpenAI 驱动的分析器接到任一 agent 示例上。 -## Python 脚本 +## Go 示例 ### Agent 工作流 -- `examples/python/agent_inbox_assistant.py` +- `examples/go/agent_inbox_assistant` 最小单封邮件 agent 边界。 -- `examples/python/agent_thread_assistant.py` +- `examples/go/agent_thread_assistant` 带 thread 感知的本地检索与回复边界。 +- `examples/go/watch_reply_agent` + JSONL watch 事件 agent 闭环。 ### 原始工具 -- `examples/python/parse_email.py` +- `examples/go/parse_email` 通过 `mailcli parse` 解析单个本地 `.eml` 文件。 -- `examples/python/reply_dry_run.py` +- `examples/go/reply_dry_run` 通过 `mailcli reply --dry-run` 编译单个 `ReplyDraft` JSON 文件。 -- `examples/python/refresh_local_thread_demo.py` +- `examples/go/refresh_local_thread_demo` 根据当前 fixture corpus 重新生成 local-thread-demo 的固定产物。 ### Provider 适配器 -- `examples/providers/template_external_provider.py` +- `examples/go/providers/template_external_provider` 同时适用于 inbox 和 thread payload 的最小 external provider 模板。 -- `examples/providers/openai_external_provider.py` - 使用 Responses API 的可选 OpenAI provider 示例。 +- `examples/go/providers/openai_external_provider` + 通过 Go 标准库 HTTP 调用 Responses API 的可选 OpenAI provider 示例。 ## Shell 脚本 diff --git a/docs/zh-CN/examples/agent-inbox-assistant.md b/docs/zh-CN/examples/agent-inbox-assistant.md index c73ac27..4f8ffdf 100644 --- a/docs/zh-CN/examples/agent-inbox-assistant.md +++ b/docs/zh-CN/examples/agent-inbox-assistant.md @@ -12,7 +12,7 @@ 示例脚本: -- `examples/python/agent_inbox_assistant.py` +- `examples/go/agent_inbox_assistant` ## 它演示了什么 @@ -25,7 +25,7 @@ ## 本地 `.eml` 示例 ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/verification.eml ``` @@ -33,7 +33,7 @@ python3 examples/python/agent_inbox_assistant.py \ ## 回复 Dry-Run 示例 ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ @@ -48,7 +48,7 @@ python3 examples/python/agent_inbox_assistant.py \ ## 已配置 inbox 示例 ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --config ~/.config/mailcli/config.yaml \ --account work \ @@ -58,7 +58,7 @@ python3 examples/python/agent_inbox_assistant.py \ ## 零网络 Fixture 示例 ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -78,13 +78,12 @@ python3 examples/python/agent_inbox_assistant.py \ 你也可以把分析步骤委托给自己的脚本或 agent runtime: ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` 外部 provider 会从 stdin 收到 JSON: @@ -111,5 +110,5 @@ python3 examples/python/agent_inbox_assistant.py \ - [Agent Provider 契约](../spec/agent-provider.md) - [Examples 索引](README.md) -- 模板 provider:`examples/providers/template_external_provider.py` +- 模板 provider:`examples/go/providers/template_external_provider` - 可选 OpenAI provider 示例:[OpenAI External Provider](openai-external-provider.md) diff --git a/docs/zh-CN/examples/agent-thread-assistant.md b/docs/zh-CN/examples/agent-thread-assistant.md index 6c4249d..753a315 100644 --- a/docs/zh-CN/examples/agent-thread-assistant.md +++ b/docs/zh-CN/examples/agent-thread-assistant.md @@ -14,7 +14,7 @@ 示例脚本: -- `examples/python/agent_thread_assistant.py` +- `examples/go/agent_thread_assistant` ## 它演示了什么 @@ -28,7 +28,7 @@ ## Inbox 驱动示例 ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config ~/.config/mailcli/config.yaml \ --account work \ @@ -41,7 +41,7 @@ python3 examples/python/agent_thread_assistant.py \ ## 零网络 Fixture 示例 ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -50,12 +50,12 @@ python3 examples/python/agent_thread_assistant.py \ --query invoice ``` -这个示例通过内置 `dir` driver,把仓库内置的 fixture 语料跑进正常的 `sync -> threads -> thread` 工作流。这里的 `--sync-limit 0` 是 Python 示例自己的 sync 参数,表示脚本会调用 `mailcli sync --limit 0`,不截断初始同步阶段。 +这个示例通过内置 `dir` driver,把仓库内置的 fixture 语料跑进正常的 `sync -> threads -> thread` 工作流。这里的 `--sync-limit 0` 会让 Go 示例调用 `mailcli sync --limit 0`,不截断初始同步阶段。 ## 复用已有本地索引示例 ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ @@ -69,15 +69,14 @@ python3 examples/python/agent_thread_assistant.py \ 你也可以把 thread 分析步骤委托给自己的脚本或 agent runtime: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ --thread-id "" \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` external provider 会收到一个 JSON payload,其中包含: @@ -119,7 +118,7 @@ external provider 会收到一个 JSON payload,其中包含: ## 输出结构 -脚本会输出一个 JSON 报告,其中包括: +Go 示例会输出一个 JSON 报告,其中包括: - `sync` - `selection` diff --git a/docs/zh-CN/examples/local-thread-demo.md b/docs/zh-CN/examples/local-thread-demo.md index 6fa8809..b4222ab 100644 --- a/docs/zh-CN/examples/local-thread-demo.md +++ b/docs/zh-CN/examples/local-thread-demo.md @@ -2,7 +2,7 @@ # 本地 Thread Demo -这个页面展示一条完整的本地 thread agent 往返链路,不需要先去读 Python 示例源码。 +这个页面展示一条完整的本地 thread agent 往返链路,不需要先去读 Go 示例源码。 它使用: @@ -36,10 +36,10 @@ make demo-local-thread-refresh make demo-local-thread-check ``` -如果你需要显式传参,底层脚本也可以直接运行: +如果你需要显式传参,底层 Go 命令也可以直接运行: ```bash -python3 examples/python/refresh_local_thread_demo.py \ +go run ./examples/go/refresh_local_thread_demo \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ @@ -187,7 +187,7 @@ Thanks, we have received the invoice notification. - [agent-report.json](../../../examples/artifacts/local-thread-demo/agent-report.json) -这个文件对应的就是 `examples/python/agent_thread_assistant.py` 在这条本地 fixture 流程上的完整输出。 +这个文件对应的就是 `examples/go/agent_thread_assistant` 在这条本地 fixture 流程上的完整输出。 ## 相关文档 diff --git a/docs/zh-CN/examples/openai-external-provider.md b/docs/zh-CN/examples/openai-external-provider.md index e4cdf01..21bacfb 100644 --- a/docs/zh-CN/examples/openai-external-provider.md +++ b/docs/zh-CN/examples/openai-external-provider.md @@ -9,16 +9,11 @@ - 主仓库不引入 OpenAI SDK 依赖 - provider 以独立子进程示例存在 - 契约仍然是 stdin / stdout JSON +- provider 使用 Go 标准库 HTTP 实现 示例文件: -- `examples/providers/openai_external_provider.py` - -## 安装 - -```bash -pip install openai -``` +- `examples/go/providers/openai_external_provider` ## 环境变量 @@ -32,27 +27,29 @@ export OPENAI_MODEL=gpt-5-mini ## 配合 Agent 示例运行 ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg examples/providers/openai_external_provider.py + --provider-command go \ + --provider-arg run \ + --provider-arg ./examples/go/providers/openai_external_provider ``` 同一个 provider 也可以直接用于 thread 场景示例: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ --thread-id "" \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg examples/providers/openai_external_provider.py + --provider-command go \ + --provider-arg run \ + --provider-arg ./examples/go/providers/openai_external_provider ``` ## 它做了什么 diff --git a/docs/zh-CN/project/github-backlog.md b/docs/zh-CN/project/github-backlog.md index 41e7519..8a8cf15 100644 --- a/docs/zh-CN/project/github-backlog.md +++ b/docs/zh-CN/project/github-backlog.md @@ -11,10 +11,11 @@ ### Milestones - `v0.1 hardening` -- `parser quality` -- `local memory` -- `contributor surface` -- `provider expansion` +- `go-only core` +- `existing mailbox setup` +- `inbox intelligence` +- `parser actions and attachments` +- `safe outbound automation` ### Labels @@ -29,9 +30,9 @@ - `good first issue` - `rfc` -## 建议第一批先开的 Issue +## 历史 Issue 草案状态 -优先创建这 8 个: +下面 8 个来自上一版路线图,当前多数已经完成或只需要作为后续扩展草案保留: 1. Align docs with actual RC capabilities 2. Add JSON contract snapshot tests for CLI commands @@ -51,15 +52,19 @@ - 第 8 项现在已经由 `docs/zh-CN/contributing/parser.md` 覆盖 - 这两份 issue 草案可以保留给后续扩展用,不必再按“首批必开”处理 -如果以当前仓库状态为起点,更值得优先保留的开放项是: +如果以当前仓库状态为起点,当前第一批应优先新建的问题是: -1. Strengthen HTML body extraction and noise filtering -2. Improve URL normalization for agent-facing actions -3. Expand parser corpus for real-world edge cases -4. 在共享层继续稳定后,再增加一个新的内置 provider +1. 收紧现有邮箱接入、配置、账户能力和 Go-first 示例 +2. 增强 inbox / thread 摘要、优先级、待办提取 +3. 将入站附件和发票入口提升为一等结构化输出 +4. 为危险动作增加 prepare / confirm 流程和本地操作日志 + +Provider 扩展和专用 Agent mailbox 暂不进入当前第一批。 ## Issue 草案 +下面的草案主要保留上一版路线图中仍有参考价值的 issue 文案。新的 Go 主线 issue 可以按上面的四个当前优先项拆分。 + ## Issue 1 Title: `Align docs with actual RC capabilities` diff --git a/docs/zh-CN/project/internal-priority.md b/docs/zh-CN/project/internal-priority.md index 57de35e..78c238b 100644 --- a/docs/zh-CN/project/internal-priority.md +++ b/docs/zh-CN/project/internal-priority.md @@ -26,108 +26,98 @@ 另外,local thread demo 固定产物的维护闭环也已经接入仓库级命令和 CI 校验。 -这意味着下一阶段不应该继续停留在“边界是否成立”,而应该转向语料、贡献杠杆和共享质量基线。 +这意味着下一阶段不应该继续停留在“边界是否成立”,而应该转向 Go-only 核心、现有邮箱体验、inbox intelligence 和安全自动化闭环。 ## 当前工作假设 接下来一个阶段,可以把 MailCLI 看成: -- 核心契约由维护者主导 -- parser 质量由维护者主导 -- 本地 memory 模型由维护者主导 +- 官方可执行能力由 Go core 承担 +- 核心契约、parser 质量、本地 memory 模型由维护者主导 +- AI provider 通过语言无关 JSON 契约外接,不进入 core +- Go 示例是官方主路径;不再维护第二条官方运行时路径 - 文档、fixtures、examples、部分 contributor surface 由社区辅助 -## 下一阶段核心五项 +## 下一阶段核心四项 -这五个任务应该被视为接下来内部开发的主线顺序。 +这四个任务应该被视为接下来内部开发的主线顺序。第五项“专用 Agent mailbox / 更大 provider 扩展”暂时不处理。 -### 1. 扩大 parser 样本集,覆盖真实失败模式 +### 1. 现有邮箱接入和配置体验:Go core 为什么排第一: -- 当前 parser 质量更受语料覆盖约束,而不是缺少基础设施 -- 更多 fixture 会让现有 heuristic 的演进更安全 -- 这是在不重定义契约的前提下,最快提升 agent 可用性的方式 +- 大多数用户已经有 Gmail、Outlook、QQ、163 或企业邮箱,不会先拥有专用 Agent 邮箱 +- 用户真正需要的是让 AI 安全理解和处理已有邮箱 +- 安装、配置、连接测试、账户能力说明都应该由 Go binary 提供,不依赖另一套语言运行时 +- 第一阶段的实现切片应让 `config init`、`config doctor`、`config test`、`config capabilities` 分别覆盖配置创建、静态诊断、联网检查和机器可读能力发现 为什么应由维护者主导: -- fixture 选择本身就在定义 parser 的质量基线 -- 第一批语料整理应体现维护者对产品方向的判断 +- 这会定义 MailCLI 面向普通用户的第一体验 +- 配置契约和账户能力输出会直接影响后续所有 Agent 工作流 -## 2. 为社区 driver 建立 fake-driver / 合规测试支架 +## 2. Inbox / thread 摘要、优先级、待办提取 为什么排第二: -- driver 贡献门槛仍然偏高 -- 共享验收标准能降低维护者 review 成本 -- 这会为后续社区 provider 扩展打开更安全的路径 +- 用户想要的是“哪些邮件重要、哪些需要回复、下一步是什么” +- 本地 thread / search 已经可用,下一步应该把它提升为可解释的 triage 信号 +- Go 层应提供结构化候选数据,LLM provider 只负责外接解释和生成建议 为什么应由维护者主导: -- 它在定义整个生态会继承的最小传输契约 -- 支架如果太弱,后面 provider PR 会变得非常嘈杂 +- 摘要和优先级信号会影响对外 JSON 形状和 prompt 使用方式 +- 本地 memory 语义一旦做差,很快会污染示例和用户工作流 -## 3. 收紧本地 search 语义与排序 +## 3. 附件、发票、验证码、链接动作提取 为什么排第三: -- 本地 memory 已经是一个真实可用的工作流 -- 更好的排序和过滤能减少不必要的完整消息加载 -- 这可以提升 agent 价值,同时不引入新的传输表面积 +- 这些是普通邮箱中最常见、最有自动化价值的信息 +- 现有 action / code 基线已经成立,后续应扩大到入站附件和发票入口 +- 这比新增 provider 更直接提升用户已有邮箱的 AI 可用性 为什么应由维护者主导: -- search 语义非常接近长期 memory 模型 -- 排序策略一旦做差,会很快污染 prompt 和 examples +- parser / schema 的字段设计就是产品质量基线 +- fixture 选择和 golden 输出需要体现维护者对真实邮件场景的判断 -## 4. 在不扩张核心契约的前提下改善出站体验 +## 4. 草稿、确认、执行、操作日志 为什么排第四: -- send / reply 已经能用,但贡献者和使用者体验还有提升空间 -- 这能在更大 provider 工作延后时,继续增强读写闭环 -- 也能让 agent 的收发链路更完整 +- 自动化必须可控,尤其是发送、删除、移动、标记这类高影响动作 +- 现有 `send` / `reply` / mailbox mutation 已经能用,下一步应补 prepare / confirm / log +- 这能让 Agent 从“会调用命令”升级为“有审计边界地执行” 为什么应由维护者主导: -- send / reply 体验很接近公开稳定契约 -- 需要由维护者决定便利性增强和 schema 膨胀之间的边界 - -## 5. 在前四项收口后,再增加一个新的内置 provider - -为什么排第五: - -- 生态广度很重要,但前提是共享层已经更容易安全扩展 -- 新增一个 provider 能加强架构故事,但前提是不能扰动核心 -- 当 parser、本地 memory 和 driver 指引更稳后,这件事更容易 review - -为什么应由维护者主导: - -- 第二个真实 provider 会强烈影响社区对扩展风格的预期 -- 目前仍应由维护者先定义传输隔离和文档质量基线 +- 它接近公开稳定 CLI 契约和用户信任边界 +- 确认 token、intent id、操作日志字段需要一开始就足够稳定 ## 推荐执行顺序 -1. 继续扩大 parser fixture 覆盖。 -2. 建立 driver 合规测试支架。 -3. 提升本地 search 语义。 -4. 改善出站体验。 -5. 增加一个新的 provider。 +1. 收紧现有邮箱接入、配置、账户能力和 Go-first 示例。 +2. 增强 inbox / thread 摘要、优先级、待办提取。 +3. 增强附件、发票、验证码、链接动作提取。 +4. 增加草稿 / 确认 / 执行 / 操作日志闭环。 ## 可以往后放的事 -这些都重要,但不应该抢走下一阶段核心五项的主线位置: +这些都重要,但不应该抢走下一阶段核心四项的主线位置: - 文档对齐收口 - parser contributor guide - 更大范围的 provider 扩展 - 更广的社区流程建设 +- 专用 Agent mailbox / 托管邮箱身份 +- 在 Go 之外继续维护第二条官方运行时路径 它们是围绕产品核心的支撑工作,不是通向更强 `v0.2` 的主路径。 ## 适合社区并行辅助的工作 -当维护者专注这五项时,社区仍然可以帮助推进: +当维护者专注这四项时,社区仍然可以帮助推进: - fixture 收集和脱敏 - examples @@ -135,6 +125,7 @@ - 小型 parser regression 报告 - contributor guides - 不改变契约的测试补充 +- 非官方语言示例,但不能让它们成为安装或使用前置条件 ## 一条简单判断规则 @@ -144,6 +135,7 @@ - parser 质量基线 - thread 摘要语义 - 本地 memory 语义 +- Go CLI 的执行 / 确认 / 审计契约 那它就应该优先由维护者主导。 diff --git a/docs/zh-CN/project/next-roadmap.md b/docs/zh-CN/project/next-roadmap.md index 398c952..6c6b084 100644 --- a/docs/zh-CN/project/next-roadmap.md +++ b/docs/zh-CN/project/next-roadmap.md @@ -8,6 +8,8 @@ 目标是把当前 RC 打磨成一个稳定、易贡献、真正服务于 agent 开发者的开源项目。 +下一阶段还有一个明确的产品技术方向:**核心运行时和官方示例全面 Go 化**。MailCLI 应该交付一个方便 Agent 调用、方便用户安装、方便维护者发布的 Go binary,而不是要求用户理解或部署另一套语言运行时。 + ## `main` 上最近已经完成的部分 RC 收口阶段最关键的一批工作已经在 `main` 上完成: @@ -29,19 +31,35 @@ RC 收口阶段最关键的一批工作已经在 `main` 上完成: ## 优先级顺序 -1. 先把当前 RC 的稳定边界收紧。 -2. 优先提升 agent 当前最有痛感的 parser 质量。 -3. 继续增强本地 memory 和 thread 工作流的可靠性。 -4. 降低新 driver / parser 贡献者的接入门槛。 -5. 在共享契约更清晰之后,再扩 provider 生态。 +1. 先把现有邮箱接入和配置体验收紧为 Go core 能力。 +2. 让 inbox / thread 摘要、优先级、待办提取成为 Go 提供的结构化命令。 +3. 继续增强附件、发票、验证码、链接动作提取,落在 Go parser / schema。 +4. 把草稿、确认、执行、操作日志做成 Go CLI 契约。 +5. 暂不优先处理专用 Agent mailbox 或更大 provider 扩展。 ## Maintainer 规则 - 稳定的机器接口优先于功能数量 +- 官方可执行能力由 Go 实现;不要引入 Go 之外的运行时作为用户安装或 Agent 调用的前置条件 +- AI provider 接入保持语言无关的 JSON 契约,同时官方示例和长期维护路径以 Go 为主 - provider 私有业务逻辑不要进入共享 parser / composer 层 - parser heuristic 不是“清理工作”,而是核心产品工作 - 既要优化“好用”,也要优化“好贡献” +## 下一阶段 Go 主线 + +这四条是当前产品方向认可后的主线: + +1. **现有邮箱接入和配置体验:Go core。** + 重点是让普通用户把已有 Gmail、Outlook、QQ、163、企业邮箱或本地 `.eml` 接进 MailCLI,而不是先要求他们拥有专用 Agent 邮箱。 + 第一阶段的具体落点是 Go-only 配置路径:`config init`、`config doctor`、`config test` 和 `config capabilities`。 +2. **Inbox / thread 摘要、优先级、待办提取:Go 提供结构化数据和命令,AI provider 可外接。** + Go 负责稳定检索、聚合、字段输出和轻量规则;LLM 负责解释、归纳和生成建议。 +3. **附件、发票、验证码、链接动作提取:Go parser / schema。** + 这些是 Agent 处理真实邮箱时最常遇到的高价值信息,应成为核心 parser 质量基线。 +4. **草稿、确认、执行、操作日志:Go CLI 契约。** + 自动化必须可控。危险动作应先形成可审计意图,再确认执行,并留下机器可读结果。 + ## Milestone 1: v0.1 收口 目标:让当前 RC 更可信、更好文档化,也更适合他人基于它集成。 @@ -91,7 +109,7 @@ RC 收口阶段最关键的一批工作已经在 `main` 上完成: - 在 driver / schema 文档中指向这条路径 - Deliverable: `.github` 模板 + 文档更新 -## Milestone 2: Parser 质量 +## Milestone 2: Parser / Schema 质量 目标:优先打磨最直接影响 agent 使用价值的部分。 @@ -99,6 +117,7 @@ RC 收口阶段最关键的一批工作已经在 `main` 上完成: - 对噪音 HTML 模板的主体提取更稳定 - 对追踪跳转链接的清洗更激进但仍可控 +- 对附件、发票、验证码、链接动作的结构化输出更完整 - fixture 覆盖更贴近真实 agent 工作流 - parser 回归更容易在发版前被发现 @@ -138,15 +157,26 @@ RC 收口阶段最关键的一批工作已经在 `main` 上完成: - 说明每个 fixture 保护的行为是什么 - Deliverable: 新 fixture + 定向回归测试 -## Milestone 3: Local Memory 与 Threads +#### Issue: 将入站附件和发票入口提升为一等结构化输出 + +- Area: parser, schema, cmd +- Problem: 普通邮箱中的高价值信息经常藏在附件、发票入口、下载链接或正文 URL 中,agent 不应该只靠全文猜测。 +- Scope: + - 在 `StandardMessage` 中设计稳定的入站附件 / 附件入口表达 + - 区分真实 MIME 附件、正文中的附件下载入口、发票查看 / 下载动作 + - 为发票、附件通知、多语言验证码增加 fixture 和 golden 覆盖 +- Deliverable: schema / parser 改动、CLI 输出、测试和 spec 更新 -目标:让本地 agent 检索体验从“能用”变成“可靠”。 +## Milestone 3: Inbox / Thread Intelligence + +目标:让 AI 基于用户已有邮箱更快、更准确地获取信息,而不是只做关键词搜索。 ### 完成标准 - `sync` 的重复运行语义更容易理解 - 用户和贡献者更容易看清楚本地缓存里到底有什么 - thread 元数据足够减少不必要的完整消息加载 +- inbox / thread 摘要能支持优先级、待办、需要回复等常见 triage 判断 ### 建议 GitHub issues @@ -174,79 +204,70 @@ RC 收口阶段最关键的一批工作已经在 `main` 上完成: - 对最终 shape 做 snapshot 测试 - Deliverable: schema / 输出增强 + 文档 -## Milestone 4: Contributor Surface +#### Issue: 增加 inbox / thread 的优先级与待办提取信号 + +- Area: internal/index, cmd, schema +- Problem: 用户最常见的问题不是“搜到邮件”,而是“哪些邮件重要、哪些需要处理、下一步做什么”。 +- Scope: + - 设计轻量、可解释的 priority / needs_reply / todo-like 信号 + - 保持 Go 层输出为结构化候选信号,不在 core 中绑定特定 LLM + - 为本地 fixture 和 thread demo 增加固定输出覆盖 +- Deliverable: Go 命令输出、schema / spec 更新、snapshot 测试 + +## Milestone 4: 安全出站闭环与操作日志 -目标:降低外部开发者做出有效 PR 的成本。 +目标:让 AI 自动化从“直接执行命令”升级为“生成意图、确认执行、记录结果”的可控闭环。 ### 完成标准 -- 新增 driver 的方式容易理解,也容易验证 -- parser 贡献者有清晰的测试和入口说明 -- 契约变更有一条不依赖“口口相传”的讨论路径 +- `send` / `reply` / `delete` / `move` / `mark` 等高影响动作可以先生成 dry-run / intent +- 确认执行有稳定 token 或 intent id,避免 agent 误操作 +- 执行结果和失败原因进入机器可读操作日志 +- 操作日志不依赖 provider 私有行为 ### 建议 GitHub issues -#### Issue: 为 driver 扩展贡献者增加 fake-driver 测试支架 +#### Issue: 为危险动作增加 prepare / confirm 流程 -状态:可复用的共享 driver 合同测试支架已完成,见 `pkg/driver/drivertest`。 - -- Area: driver, tests, docs -- Problem: 外部 driver 的验证门槛仍偏高 +- Area: cmd, schema, docs +- Problem: Agent 可以生成发送、删除、移动等动作,但直接执行会放大误操作风险。 - Scope: - - 增加可复用的 driver 合规测试辅助或 fixture - - 明确 list / fetch / send 的最小行为要求 - - 文档化最低接受门槛 -- Deliverable: 可复用测试支架 + 贡献文档 + - 为发送和 mailbox mutation 设计 `prepare` 输出 + - 使用 intent id 或确认 token 执行同一意图 + - 保持 dry-run、prepare、confirm 的输出都适合 Agent 读取 +- Deliverable: Go CLI 契约、schema、测试和文档 -#### Issue: 编写 parser contributor guide +#### Issue: 增加本地操作日志 -- Area: docs -- Problem: parser 是高价值区域,但当前进入路径仍然偏隐性 +- Area: cmd, internal, docs +- Problem: Agent 自动化需要事后审计:执行了什么、为什么失败、对应哪个 message / thread。 - Scope: - - 说明 fixture 布局、golden tests 和 parser 设计约束 - - 说明哪些 heuristic 是允许的,哪些地方不允许随意漂移 - - 指向最相关的 parser 包与测试文件 -- Deliverable: 新贡献文档 - -状态:已完成,见 `docs/zh-CN/contributing/parser.md`。 + - 记录操作类型、账户、目标 ID、intent id、结果、错误码和时间 + - 提供 `mailcli operations list/show` 或等价查询入口 + - 避免记录秘密字段和完整敏感正文 +- Deliverable: Go 存储 / CLI、测试和安全说明 -## Milestone 5: Provider 扩展 +## 暂缓:Provider 扩展与专用 Agent Mailbox -目标:在不破坏共享模型的前提下扩展生态。 - -这个里程碑应该建立在前几个阶段已经基本收口之后。 - -### 建议 GitHub issues +腾讯 Agent Mail 证明了“专用 Agent 邮箱身份”有价值,但 MailCLI 当前主线不是托管邮箱服务。下一阶段应优先帮助用户用 AI 处理已有邮箱。 -#### Issue: 增加一个新的内置 provider,并配齐测试与文档 +因此暂缓: -- Area: driver, docs -- Problem: 当仓库里不止一种真实接入路径时,生态故事会更有说服力 -- Scope: - - 实现一个额外的 provider 或 provider 风格 - - 保持传输逻辑与 parser / composer 逻辑分离 - - 补齐配置、限制与测试策略文档 -- Deliverable: 新 driver + 测试 + 文档 - -#### Issue: 定义 driver 合规性检查清单 - -状态:基线版清单已经在当前 driver spec 和 contributor docs 中完成。 +- 内建重 OAuth 流程 +- 提供托管式 `@agent` 邮箱身份 +- 大范围 provider 扩展 +- 运行时插件加载 -- Area: docs, tests -- Problem: 社区 driver 需要共享质量门槛 -- Scope: - - 定义 list、fetch、send、config validation 的必备行为 - - 把这些期望映射到测试和贡献说明 - - 保持清单足够稳定,便于 PR review 直接引用 -- Deliverable: spec 或 contributing 文档更新 +后续只有在 Go core 的接入、检索、提取、确认执行闭环稳定之后,才重新评估专用 Agent mailbox 或新增 provider。 ## 建议在 GitHub 中建立的 Milestones - `v0.1 hardening` -- `parser quality` -- `local memory` -- `contributor surface` -- `provider expansion` +- `go-only core` +- `existing mailbox setup` +- `inbox intelligence` +- `parser actions and attachments` +- `safe outbound automation` ## 建议标签 @@ -265,8 +286,10 @@ RC 收口阶段最关键的一批工作已经在 `main` 上完成: - 完整终端 mail client 体验 - 在 core 中引入重 OAuth 认证流 +- 提供托管式专用 Agent mailbox - 运行时插件加载 - 在共享层中加入 provider 私有业务策略 - 试图一次性解决所有邮箱厂商 +- 在 Go 之外再引入第二条官方运行时路径 -最强的下一步,不是把边界做得更宽,而是把当前 agent 边界做得更锋利。 +最强的下一步,不是把边界做得更宽,而是用 Go 把“AI 安全处理用户已有邮箱”的边界做得更锋利。 diff --git a/docs/zh-CN/release/announcement-kit.md b/docs/zh-CN/release/announcement-kit.md index c7187d8..dbb0b24 100644 --- a/docs/zh-CN/release/announcement-kit.md +++ b/docs/zh-CN/release/announcement-kit.md @@ -61,7 +61,7 @@ MailCLI 是一个面向 agent 的开源邮件接口。 - 通过更强的 HTML 主体提取和追踪链接清洗提升 parser 输出质量 - 通过 `DraftMessage` 和 `ReplyDraft` 编译新邮件与回复 - 同时支持单封邮件和 thread 场景的 external provider 工作流 -- 提供 Python、shell、template provider 和可选 OpenAI provider 示例 +- 提供 Go、shell、template provider 和可选 OpenAI provider 示例 - 提供完整的本地往返 demo 和开箱即用的 fixture 配置 建议集成方视为稳定边界的部分: diff --git a/docs/zh-CN/release/github-v0.1.0-rc1.md b/docs/zh-CN/release/github-v0.1.0-rc1.md index 61dd744..9999e82 100644 --- a/docs/zh-CN/release/github-v0.1.0-rc1.md +++ b/docs/zh-CN/release/github-v0.1.0-rc1.md @@ -18,7 +18,7 @@ - `DraftMessage` - `ReplyDraft` - `SendResult` -- 提供 Python / shell 示例,以及 external provider 契约和可选 OpenAI provider 示例 +- 提供 Go / shell 示例,以及 external provider 契约和可选 OpenAI provider 示例 - 提供仓库内可直接运行的 fixture 配置和完整本地 thread demo ## 这个 RC 已包含 @@ -76,7 +76,8 @@ - `go test ./...` - `go build ./cmd/mailcli` -- `python3 -m py_compile examples/python/*.py examples/providers/*.py` +- `go test ./examples` +- `make demo-local-thread-check` ## 最快首次体验 diff --git a/docs/zh-CN/release/v0.1-rc.md b/docs/zh-CN/release/v0.1-rc.md index 7f2fa20..ce83ed3 100644 --- a/docs/zh-CN/release/v0.1-rc.md +++ b/docs/zh-CN/release/v0.1-rc.md @@ -14,7 +14,7 @@ - 将近期邮件同步到本地索引并在本地检索 - 查看 thread 摘要和完整本地 thread,服务 agent 分拣 - 编译并发送出站草稿与回复 -- 与 Python、shell 以及外部 agent provider 协作 +- 与 Go、shell 以及外部 agent provider 协作 ## 当前范围 @@ -71,7 +71,7 @@ go build -o mailcli ./cmd/mailcli ./mailcli sync --config examples/config/fixtures-dir.yaml --account fixtures --index /tmp/mailcli-fixtures-index.json --limit 20 ./mailcli threads --index /tmp/mailcli-fixtures-index.json invoice -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --config examples/config/fixtures-dir.yaml \ --account fixtures \ diff --git a/docs/zh-CN/release/v0.1.0.md b/docs/zh-CN/release/v0.1.0.md index 9ceebb9..fcf6e9a 100644 --- a/docs/zh-CN/release/v0.1.0.md +++ b/docs/zh-CN/release/v0.1.0.md @@ -49,7 +49,7 @@ - `tools/openai.json` — OpenAI Function Calling 格式(12 个工具) - `tools/anthropic.json` — Anthropic Tool Use 格式(12 个工具) -- `tools/agent_example.py` — 参考实现:`watch → LLM → reply` 管道 +- `examples/go/watch_reply_agent` — 参考实现:`watch → agent → reply` 管道 - `tools/README.md` — 集成指南,含 `tool_to_cmd` 分发模式 ### Parser 改进 @@ -62,9 +62,9 @@ ### 示例与 Fixture -- `examples/python/agent_thread_assistant.py` — 线程模式 AI Agent,支持外部 provider -- `examples/python/agent_inbox_assistant.py` — 收件箱模式 AI Agent -- `examples/providers/` — 模板 provider 和 OpenAI 外部 provider 实现 +- `examples/go/agent_thread_assistant` — 线程模式 AI Agent,支持外部 provider +- `examples/go/agent_inbox_assistant` — 收件箱模式 AI Agent +- `examples/go/providers/` — 模板 provider 和 OpenAI 外部 provider 实现 - `examples/artifacts/local-thread-demo/` — 完整本地线程 demo 制品 --- @@ -112,7 +112,7 @@ go build -o mailcli ./cmd/mailcli # Watch + AI Agent mailcli watch --account work --index ~/.config/mailcli/index.db --auto-sync \ - | python3 tools/agent_example.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` 完整文档:[docs/zh-CN/](../../zh-CN/) diff --git a/docs/zh-CN/spec/agent-provider.md b/docs/zh-CN/spec/agent-provider.md index 3805dd9..3d66e6b 100644 --- a/docs/zh-CN/spec/agent-provider.md +++ b/docs/zh-CN/spec/agent-provider.md @@ -4,7 +4,7 @@ ## 目的 -Python inbox agent 和 thread agent 示例都支持可插拔的 external provider 模式。 +Go inbox agent 和 thread agent 示例都支持可插拔的 external provider 模式。 这样仓库本身可以保持零 LLM SDK 依赖,同时又为 OpenAI、Claude、本地模型或自定义 agent runtime 提供稳定的接入点。 @@ -13,13 +13,12 @@ Python inbox agent 和 thread agent 示例都支持可插拔的 external provide 示例调用: ```bash -python3 examples/python/agent_inbox_assistant.py \ +go run ./examples/go/agent_inbox_assistant \ --mailcli-bin ./mailcli \ --email testdata/emails/plaintext.eml \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` 外部 provider 会以子进程方式运行。 @@ -27,15 +26,14 @@ python3 examples/python/agent_inbox_assistant.py \ 同一个契约也适用于: ```bash -python3 examples/python/agent_thread_assistant.py \ +go run ./examples/go/agent_thread_assistant \ --mailcli-bin ./mailcli \ --index /tmp/mailcli-index.json \ --skip-sync \ --thread-id "" \ --from-address support@nono.im \ --agent-provider external \ - --provider-command python3 \ - --provider-arg ./my_provider.py + --provider-command ./my_provider ``` ## 输入契约 diff --git a/docs/zh-CN/spec/config.md b/docs/zh-CN/spec/config.md index f6878e5..0377924 100644 --- a/docs/zh-CN/spec/config.md +++ b/docs/zh-CN/spec/config.md @@ -16,6 +16,28 @@ MailCLI 使用本地 YAML 配置文件来完成账户选择和传输设置。 ~/.config/mailcli/config.yaml ``` +## 创建配置 + +可以直接用 Go binary 提供的 `config init` 创建 starter config: + +```bash +mailcli config init \ + --config ~/.config/mailcli/config.yaml \ + --account work \ + --driver imap \ + --host imap.example.com \ + --port 993 \ + --username you@example.com \ + --password-env MAILCLI_IMAP_PASSWORD \ + --smtp-host smtp.example.com \ + --smtp-port 587 \ + --smtp-password-env MAILCLI_SMTP_PASSWORD +``` + +`config init` 会把秘密字段写成 `${MAILCLI_IMAP_PASSWORD}`、`${MAILCLI_SMTP_PASSWORD}` 这样的环境变量引用。它不会要求输入、写入或打印原始密码。 + +如果配置文件已经存在,该命令默认拒绝覆盖;需要显式传入 `--force`。由该命令创建的配置文件权限为 `0600`。 + ## 示例 ```yaml @@ -119,6 +141,101 @@ smtp_password: ${MAILCLI_SMTP_PASSWORD} - 通过环境变量注入秘密值 - 不要提交真实账户密码 +## 配置诊断 + +Agent 和 setup 脚本可以在不连接 IMAP / SMTP 的前提下做本地静态诊断: + +```bash +mailcli config doctor --config ~/.config/mailcli/config.yaml +``` + +该命令输出 JSON。顶层 `status` 为 `ok`、`warning` 或 `error`,并包含每个账户的检查结果、账户能力,以及当存在警告或错误时的扁平 `problems` 列表。 + +秘密字段会同时看 raw config 和 resolved config。如果配置里存在 `password: ${MAILCLI_IMAP_PASSWORD}`,但对应环境变量没有设置,`config doctor` 会报告 `imap_password_env_unset`,而不是打印或保存秘密值。 + +缩略输出形状示例: + +```json +{ + "config_path": "/Users/you/.config/mailcli/config.yaml", + "status": "warning", + "accounts": [ + { + "name": "work", + "driver": "imap", + "status": "warning", + "capabilities": { + "account": "work", + "driver": "imap", + "mailbox": "INBOX" + }, + "checks": [ + { + "status": "warning", + "code": "smtp_port_missing", + "message": "SMTP port must be greater than 0", + "field": "smtp_port" + } + ] + } + ], + "problems": [ + { + "status": "warning", + "code": "smtp_port_missing", + "message": "SMTP port must be greater than 0", + "field": "smtp_port" + } + ] +} +``` + +`config doctor` 不会输出配置中的 `password` 或 `smtp_password` 值。需要真正联网检查连接时,使用 `mailcli config test`。 + +## 能力发现 + +Agent 可以通过下面的命令读取当前账户的机器可用能力: + +```bash +mailcli config capabilities --config ~/.config/mailcli/config.yaml --account work +``` + +该命令只读取本地配置和内置 driver 的已知能力,不会连接 IMAP / SMTP,也不会输出 `password` 或 `smtp_password`。 + +输出示例: + +```json +{ + "account": "work", + "driver": "imap", + "mailbox": "INBOX", + "capabilities": { + "list": true, + "fetch_raw": true, + "search": true, + "threads": true, + "watch": true, + "send": true, + "reply": true, + "delete": true, + "move": true, + "mark_read": true, + "local_index": true + }, + "configuration": { + "inbound_configured": true, + "outbound_configured": true, + "uses_local_storage": false + } +} +``` + +用途: + +- onboarding 时判断账户配置是否完整 +- 让 Agent 在调用 `send`、`watch`、`delete` 等命令前先判断能力 +- 为后续 prepare / confirm / operation log 工作流提供账户能力边界 + ## v0.1 RC 明确不做的事 - 暂不内建 OAuth 流程 diff --git a/docs/zh-CN/spec/outbound-message.md b/docs/zh-CN/spec/outbound-message.md index 04fb964..dc4a3e2 100644 --- a/docs/zh-CN/spec/outbound-message.md +++ b/docs/zh-CN/spec/outbound-message.md @@ -165,7 +165,7 @@ cat draft.json | mailcli send - cat reply.json | mailcli reply - ``` -这样接口保持语言无关,适合 agent、shell、Python 和 Node.js 调用。 +这样接口保持语言无关,适合 agent、shell、Go、Node.js 和其他 runtime 调用。 ## 当前状态 diff --git a/docs/zh-CN/spec/watch.md b/docs/zh-CN/spec/watch.md index a81febc..b6a6118 100644 --- a/docs/zh-CN/spec/watch.md +++ b/docs/zh-CN/spec/watch.md @@ -137,7 +137,7 @@ mailcli watch \ --account work \ --index ~/.config/mailcli/index.db \ --auto-sync \ - | python3 my_agent.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` ## Agent 集成模式 @@ -147,7 +147,7 @@ mailcli watch \ ```bash mailcli watch --account work --index ~/.config/mailcli/index.db \ | while IFS= read -r line; do - echo "$line" | python3 handle_event.py + echo "$line" | ./handle_event done ``` @@ -155,12 +155,12 @@ mailcli watch --account work --index ~/.config/mailcli/index.db \ ```bash mailcli watch --account work --index ~/.config/mailcli/index.db \ - | python3 tools/agent_example.py + | go run ./examples/go/watch_reply_agent --from-address support@nono.im ``` Agent 从 stdin 逐行读取,每行是完整 JSON 事件。通过 `event` 字段过滤,只处理 `new_message`。 -参考实现见 [tools/agent_example.py](../../../tools/agent_example.py)。 +参考实现见 [watch_reply_agent](../../../examples/go/watch_reply_agent/main.go)。 ## 多邮箱模式 diff --git a/examples/artifacts/local-thread-demo/agent-report.json b/examples/artifacts/local-thread-demo/agent-report.json index 2f141e5..5654f14 100644 --- a/examples/artifacts/local-thread-demo/agent-report.json +++ b/examples/artifacts/local-thread-demo/agent-report.json @@ -1,220 +1,220 @@ { - "tool": "mailcli-thread-agent-example", - "source": { - "mode": "local_thread", - "config": "examples/config/fixtures-dir.yaml", + "analysis": { + "decision": "draft_reply", + "provider": "builtin", + "summary": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." + }, + "latest_message": { "account": "fixtures", - "mailbox": null, - "index": "/tmp/mailcli-fixtures-index.json", - "query": "invoice", - "thread_id": null, - "skip_sync": false + "id": "invoice.eml", + "indexed_at": "2026-03-27T15:11:13Z", + "mailbox": "INBOX", + "message": { + "actions": [ + { + "label": "View invoice", + "type": "view_invoice", + "url": "https://billing.example.com/invoices/123" + }, + { + "label": "Pay invoice", + "type": "pay_invoice", + "url": "https://billing.example.com/pay/123" + } + ], + "content": { + "body_md": "## Your invoice is ready\n\nInvoice #123 is now available in your billing portal.\n\n[View invoice](https://billing.example.com/invoices/123)\n\n[Pay invoice](https://billing.example.com/pay/123)", + "format": "markdown", + "snippet": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." + }, + "id": "", + "meta": { + "date": "2026-03-26T05:00:00Z", + "from": { + "address": "billing@example.com", + "name": "Billing Team" + }, + "message_id": "", + "subject": "Your April invoice is ready", + "to": [ + { + "address": "nono@example.com" + } + ] + }, + "token_usage": { + "estimated_input_tokens": 18 + } + }, + "thread_id": "" + }, + "reply": { + "draft": { + "account": "fixtures", + "body_text": "Thanks, we have received the invoice notification.", + "from": { + "address": "support@nono.im" + }, + "reply_to_id": "invoice.eml", + "to": [ + { + "address": "billing@example.com", + "name": "Billing Team" + } + ] + }, + "mime": "From: support@nono.im\r\nTo: Billing Team \r\nSubject: Re: Your April invoice is ready\r\nMessage-ID: \nIn-Reply-To: \r\nReferences: \r\nDate: Fri, 27 Mar 2026 15:11:13 +0000\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThanks, we have received the invoice notification.", + "mode": "dry_run" }, "selection": { "account": "fixtures", - "mailbox": "INBOX", - "thread_id": "", - "subject": "Your April invoice is ready", - "latest_date": "2026-03-26T05:00:00Z", - "last_message_id": "invoice.eml", - "last_message_from": "Billing Team ", - "last_message_preview": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", + "action_count": 2, "action_types": [ "pay_invoice", "view_invoice" ], - "has_codes": false, "code_count": 0, - "action_count": 2, + "has_codes": false, + "last_message_from": "Billing Team ", + "last_message_id": "invoice.eml", + "last_message_preview": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", + "latest_date": "2026-03-26T05:00:00Z", + "mailbox": "INBOX", "message_count": 1, + "message_ids": [ + "invoice.eml" + ], "participant_count": 2, "participants": [ "Billing Team ", "nono@example.com" ], - "message_ids": [ - "invoice.eml" - ], "score": 85, - "selection_strategy": "top_thread" + "selection_strategy": "top_thread", + "subject": "Your April invoice is ready", + "thread_id": "" }, - "thread_summaries": [ + "source": { + "account": "fixtures", + "config": "examples/config/fixtures-dir.yaml", + "index": "/tmp/mailcli-fixtures-index.json", + "mailbox": "", + "mode": "local_thread", + "query": "invoice", + "skip_sync": false, + "thread_id": "" + }, + "sync": { + "account": "fixtures", + "fetched_count": 21, + "index_path": "/tmp/mailcli-fixtures-index.json", + "indexed_count": 21, + "listed_count": 21, + "mailbox": "INBOX", + "refreshed_count": 0, + "skipped_count": 0 + }, + "thread_messages": [ { "account": "fixtures", + "id": "invoice.eml", + "indexed_at": "2026-03-27T15:11:13Z", "mailbox": "INBOX", - "thread_id": "", - "subject": "Your April invoice is ready", - "latest_date": "2026-03-26T05:00:00Z", - "last_message_id": "invoice.eml", - "last_message_from": "Billing Team ", - "last_message_preview": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", + "message": { + "actions": [ + { + "label": "View invoice", + "type": "view_invoice", + "url": "https://billing.example.com/invoices/123" + }, + { + "label": "Pay invoice", + "type": "pay_invoice", + "url": "https://billing.example.com/pay/123" + } + ], + "content": { + "body_md": "## Your invoice is ready\n\nInvoice #123 is now available in your billing portal.\n\n[View invoice](https://billing.example.com/invoices/123)\n\n[Pay invoice](https://billing.example.com/pay/123)", + "format": "markdown", + "snippet": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." + }, + "id": "", + "meta": { + "date": "2026-03-26T05:00:00Z", + "from": { + "address": "billing@example.com", + "name": "Billing Team" + }, + "message_id": "", + "subject": "Your April invoice is ready", + "to": [ + { + "address": "nono@example.com" + } + ] + }, + "token_usage": { + "estimated_input_tokens": 18 + } + }, + "thread_id": "" + } + ], + "thread_summaries": [ + { + "account": "fixtures", + "action_count": 2, "action_types": [ "pay_invoice", "view_invoice" ], - "has_codes": false, "code_count": 0, - "action_count": 2, + "has_codes": false, + "last_message_from": "Billing Team ", + "last_message_id": "invoice.eml", + "last_message_preview": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", + "latest_date": "2026-03-26T05:00:00Z", + "mailbox": "INBOX", "message_count": 1, + "message_ids": [ + "invoice.eml" + ], "participant_count": 2, "participants": [ "Billing Team ", "nono@example.com" ], - "message_ids": [ - "invoice.eml" - ], - "score": 85 + "score": 85, + "subject": "Your April invoice is ready", + "thread_id": "" }, { "account": "fixtures", - "mailbox": "INBOX", - "thread_id": "", - "subject": "你的四月账单已生成", - "latest_date": "2026-03-28T03:30:00Z", - "last_message_id": "invoice_cn.eml", - "last_message_from": "账单中心 ", - "last_message_preview": "## 你的四月账单已生成 请尽快处理本月账单。 [查看发票](https://example.cn/billing/document/123) [支付账单](https://example.cn/billing/checkout/123)", + "action_count": 2, "action_types": [ "pay_invoice", "view_invoice" ], - "has_codes": false, "code_count": 0, - "action_count": 2, + "has_codes": false, + "last_message_from": "账单中心 ", + "last_message_id": "invoice_cn.eml", + "last_message_preview": "## 你的四月账单已生成 请尽快处理本月账单。 [查看发票](https://example.cn/billing/document/123) [支付账单](https://example.cn/billing/checkout/123)", + "latest_date": "2026-03-28T03:30:00Z", + "mailbox": "INBOX", "message_count": 1, + "message_ids": [ + "invoice_cn.eml" + ], "participant_count": 2, "participants": [ "nono@example.com", "账单中心 " ], - "message_ids": [ - "invoice_cn.eml" - ], - "score": 16 - } - ], - "thread_messages": [ - { - "account": "fixtures", - "mailbox": "INBOX", - "id": "invoice.eml", - "indexed_at": "2026-03-27T15:11:13Z", - "thread_id": "", - "message": { - "id": "", - "meta": { - "from": { - "name": "Billing Team", - "address": "billing@example.com" - }, - "to": [ - { - "address": "nono@example.com" - } - ], - "subject": "Your April invoice is ready", - "date": "2026-03-26T05:00:00Z", - "message_id": "" - }, - "content": { - "format": "markdown", - "body_md": "## Your invoice is ready\n\nInvoice #123 is now available in your billing portal.\n\n[View invoice](https://billing.example.com/invoices/123)\n\n[Pay invoice](https://billing.example.com/pay/123)", - "snippet": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." - }, - "actions": [ - { - "type": "view_invoice", - "label": "View invoice", - "url": "https://billing.example.com/invoices/123" - }, - { - "type": "pay_invoice", - "label": "Pay invoice", - "url": "https://billing.example.com/pay/123" - } - ], - "token_usage": { - "estimated_input_tokens": 18 - } - } + "score": 16, + "subject": "你的四月账单已生成", + "thread_id": "" } ], - "latest_message": { - "account": "fixtures", - "mailbox": "INBOX", - "id": "invoice.eml", - "indexed_at": "2026-03-27T15:11:13Z", - "thread_id": "", - "message": { - "id": "", - "meta": { - "from": { - "name": "Billing Team", - "address": "billing@example.com" - }, - "to": [ - { - "address": "nono@example.com" - } - ], - "subject": "Your April invoice is ready", - "date": "2026-03-26T05:00:00Z", - "message_id": "" - }, - "content": { - "format": "markdown", - "body_md": "## Your invoice is ready\n\nInvoice #123 is now available in your billing portal.\n\n[View invoice](https://billing.example.com/invoices/123)\n\n[Pay invoice](https://billing.example.com/pay/123)", - "snippet": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." - }, - "actions": [ - { - "type": "view_invoice", - "label": "View invoice", - "url": "https://billing.example.com/invoices/123" - }, - { - "type": "pay_invoice", - "label": "Pay invoice", - "url": "https://billing.example.com/pay/123" - } - ], - "token_usage": { - "estimated_input_tokens": 18 - } - } - }, - "analysis": { - "decision": "draft_reply", - "summary": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", - "provider": "builtin" - }, - "sync": { - "account": "fixtures", - "mailbox": "INBOX", - "listed_count": 21, - "fetched_count": 21, - "indexed_count": 21, - "skipped_count": 0, - "refreshed_count": 0, - "index_path": "/tmp/mailcli-fixtures-index.json" - }, - "reply": { - "mode": "dry_run", - "draft": { - "from": { - "address": "support@nono.im" - }, - "to": [ - { - "address": "billing@example.com", - "name": "Billing Team" - } - ], - "body_text": "Thanks, we have received the invoice notification.", - "account": "fixtures", - "reply_to_id": "invoice.eml" - }, - "mime": "From: support@nono.im\nTo: Billing Team \nSubject: Re: Your April invoice is ready\nMessage-ID: \nIn-Reply-To: \nReferences: \nDate: Fri, 27 Mar 2026 15:11:13 +0000\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\n\nThanks, we have received the invoice notification." - } + "tool": "mailcli-thread-agent-example" } diff --git a/examples/artifacts/local-thread-demo/reply.draft.json b/examples/artifacts/local-thread-demo/reply.draft.json index 1e84a6e..2a6900f 100644 --- a/examples/artifacts/local-thread-demo/reply.draft.json +++ b/examples/artifacts/local-thread-demo/reply.draft.json @@ -1,14 +1,14 @@ { + "account": "fixtures", + "body_text": "Thanks, we have received the invoice notification.", "from": { "address": "support@nono.im" }, + "reply_to_id": "invoice.eml", "to": [ { "address": "billing@example.com", "name": "Billing Team" } - ], - "body_text": "Thanks, we have received the invoice notification.", - "account": "fixtures", - "reply_to_id": "invoice.eml" + ] } diff --git a/examples/artifacts/local-thread-demo/sync.json b/examples/artifacts/local-thread-demo/sync.json index 2381975..e9262a6 100644 --- a/examples/artifacts/local-thread-demo/sync.json +++ b/examples/artifacts/local-thread-demo/sync.json @@ -1,10 +1,10 @@ { "account": "fixtures", - "mailbox": "INBOX", - "listed_count": 21, "fetched_count": 21, + "index_path": "/tmp/mailcli-fixtures-index.json", "indexed_count": 21, - "skipped_count": 0, + "listed_count": 21, + "mailbox": "INBOX", "refreshed_count": 0, - "index_path": "/tmp/mailcli-fixtures-index.json" + "skipped_count": 0 } diff --git a/examples/artifacts/local-thread-demo/thread.json b/examples/artifacts/local-thread-demo/thread.json index 1a7b2b7..f72e2ea 100644 --- a/examples/artifacts/local-thread-demo/thread.json +++ b/examples/artifacts/local-thread-demo/thread.json @@ -1,46 +1,46 @@ [ { "account": "fixtures", - "mailbox": "INBOX", "id": "invoice.eml", "indexed_at": "2026-03-27T15:11:13Z", - "thread_id": "", + "mailbox": "INBOX", "message": { - "id": "", - "meta": { - "from": { - "name": "Billing Team", - "address": "billing@example.com" - }, - "to": [ - { - "address": "nono@example.com" - } - ], - "subject": "Your April invoice is ready", - "date": "2026-03-26T05:00:00Z", - "message_id": "" - }, - "content": { - "format": "markdown", - "body_md": "## Your invoice is ready\n\nInvoice #123 is now available in your billing portal.\n\n[View invoice](https://billing.example.com/invoices/123)\n\n[Pay invoice](https://billing.example.com/pay/123)", - "snippet": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." - }, "actions": [ { - "type": "view_invoice", "label": "View invoice", + "type": "view_invoice", "url": "https://billing.example.com/invoices/123" }, { - "type": "pay_invoice", "label": "Pay invoice", + "type": "pay_invoice", "url": "https://billing.example.com/pay/123" } ], + "content": { + "body_md": "## Your invoice is ready\n\nInvoice #123 is now available in your billing portal.\n\n[View invoice](https://billing.example.com/invoices/123)\n\n[Pay invoice](https://billing.example.com/pay/123)", + "format": "markdown", + "snippet": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi..." + }, + "id": "", + "meta": { + "date": "2026-03-26T05:00:00Z", + "from": { + "address": "billing@example.com", + "name": "Billing Team" + }, + "message_id": "", + "subject": "Your April invoice is ready", + "to": [ + { + "address": "nono@example.com" + } + ] + }, "token_usage": { "estimated_input_tokens": 18 } - } + }, + "thread_id": "" } ] diff --git a/examples/artifacts/local-thread-demo/threads.json b/examples/artifacts/local-thread-demo/threads.json index ae61b27..c89e680 100644 --- a/examples/artifacts/local-thread-demo/threads.json +++ b/examples/artifacts/local-thread-demo/threads.json @@ -1,56 +1,56 @@ [ { "account": "fixtures", - "mailbox": "INBOX", - "thread_id": "", - "subject": "Your April invoice is ready", - "latest_date": "2026-03-26T05:00:00Z", - "last_message_id": "invoice.eml", - "last_message_from": "Billing Team ", - "last_message_preview": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", + "action_count": 2, "action_types": [ "pay_invoice", "view_invoice" ], - "has_codes": false, "code_count": 0, - "action_count": 2, + "has_codes": false, + "last_message_from": "Billing Team ", + "last_message_id": "invoice.eml", + "last_message_preview": "## Your invoice is ready Invoice #123 is now available in your billing portal. [View invoice](https://billing.example.com/invoices/123) [Pay invoice](https://bi...", + "latest_date": "2026-03-26T05:00:00Z", + "mailbox": "INBOX", "message_count": 1, + "message_ids": [ + "invoice.eml" + ], "participant_count": 2, "participants": [ "Billing Team ", "nono@example.com" ], - "message_ids": [ - "invoice.eml" - ], - "score": 85 + "score": 85, + "subject": "Your April invoice is ready", + "thread_id": "" }, { "account": "fixtures", - "mailbox": "INBOX", - "thread_id": "", - "subject": "你的四月账单已生成", - "latest_date": "2026-03-28T03:30:00Z", - "last_message_id": "invoice_cn.eml", - "last_message_from": "账单中心 ", - "last_message_preview": "## 你的四月账单已生成 请尽快处理本月账单。 [查看发票](https://example.cn/billing/document/123) [支付账单](https://example.cn/billing/checkout/123)", + "action_count": 2, "action_types": [ "pay_invoice", "view_invoice" ], - "has_codes": false, "code_count": 0, - "action_count": 2, + "has_codes": false, + "last_message_from": "账单中心 ", + "last_message_id": "invoice_cn.eml", + "last_message_preview": "## 你的四月账单已生成 请尽快处理本月账单。 [查看发票](https://example.cn/billing/document/123) [支付账单](https://example.cn/billing/checkout/123)", + "latest_date": "2026-03-28T03:30:00Z", + "mailbox": "INBOX", "message_count": 1, + "message_ids": [ + "invoice_cn.eml" + ], "participant_count": 2, "participants": [ "nono@example.com", "账单中心 " ], - "message_ids": [ - "invoice_cn.eml" - ], - "score": 16 + "score": 16, + "subject": "你的四月账单已生成", + "thread_id": "" } ] diff --git a/examples/examples_test.go b/examples/examples_test.go index 4a1ee69..68846f0 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -2,6 +2,8 @@ package examples_test import ( "encoding/json" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -14,13 +16,10 @@ import ( ) func TestAgentInboxAssistantCapturesVerificationCode(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_inbox_assistant", "--mailcli-bin", mailcliBin, "--email", filepath.Join(repoRoot, "testdata/emails/verification.eml"), ) @@ -29,11 +28,7 @@ func TestAgentInboxAssistantCapturesVerificationCode(t *testing.T) { t.Fatalf("agent example failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) analysis := mustMap(t, report["analysis"]) if analysis["decision"] != "capture_code" { t.Fatalf("expected capture_code decision, got %#v", analysis["decision"]) @@ -44,7 +39,6 @@ func TestAgentInboxAssistantCapturesVerificationCode(t *testing.T) { if len(codes) != 1 { t.Fatalf("expected one code, got %#v", codes) } - code := mustMap(t, codes[0]) if code["value"] != "123456" { t.Fatalf("expected verification code 123456, got %#v", code["value"]) @@ -52,13 +46,10 @@ func TestAgentInboxAssistantCapturesVerificationCode(t *testing.T) { } func TestAgentInboxAssistantBuildsReplyDryRun(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_inbox_assistant", "--mailcli-bin", mailcliBin, "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), "--from-address", "support@nono.im", @@ -69,11 +60,7 @@ func TestAgentInboxAssistantBuildsReplyDryRun(t *testing.T) { t.Fatalf("agent example failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) analysis := mustMap(t, report["analysis"]) if analysis["decision"] != "draft_reply" { t.Fatalf("expected draft_reply decision, got %#v", analysis["decision"]) @@ -85,10 +72,7 @@ func TestAgentInboxAssistantBuildsReplyDryRun(t *testing.T) { t.Fatalf("expected reply_to_message_id to be propagated, got %#v", draft["reply_to_message_id"]) } - mime, ok := reply["mime"].(string) - if !ok { - t.Fatalf("expected reply mime string, got %#v", reply["mime"]) - } + mime := mustString(t, reply["mime"]) if !strings.Contains(mime, "In-Reply-To: ") { t.Fatalf("expected reply mime to contain In-Reply-To header, got %q", mime) } @@ -98,30 +82,21 @@ func TestAgentInboxAssistantBuildsReplyDryRun(t *testing.T) { } func TestAgentInboxAssistantSupportsFixtureDirConfig(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_inbox_assistant", "--mailcli-bin", mailcliBin, "--config", filepath.Join(repoRoot, "examples/config/fixtures-dir.yaml"), "--account", "fixtures", "--message-id", "invoice.eml", ) - cmd.Dir = filepath.Join(repoRoot, "examples") - output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("agent example with fixture dir config failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) message := mustMap(t, report["message"]) meta := mustMap(t, message["meta"]) if meta["subject"] != "Your April invoice is ready" { @@ -130,15 +105,12 @@ func TestAgentInboxAssistantSupportsFixtureDirConfig(t *testing.T) { } func TestAgentThreadAssistantBuildsReplyDryRunFromLocalThread(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) configPath := writeTempFile(t, "config.yaml", "current_account: demo\naccounts:\n - name: demo\n driver: stub\n") indexPath := filepath.Join(t.TempDir(), "index.json") - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_thread_assistant", "--mailcli-bin", mailcliBin, "--config", configPath, "--account", "demo", @@ -152,11 +124,7 @@ func TestAgentThreadAssistantBuildsReplyDryRunFromLocalThread(t *testing.T) { t.Fatalf("thread agent example failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) syncResult := mustMap(t, report["sync"]) if syncResult["indexed_count"] != float64(2) { t.Fatalf("expected sync to index two stub messages, got %#v", syncResult["indexed_count"]) @@ -179,25 +147,19 @@ func TestAgentThreadAssistantBuildsReplyDryRunFromLocalThread(t *testing.T) { t.Fatalf("expected reply target to use latest sender, got %#v", firstTo["address"]) } - mime, ok := reply["mime"].(string) - if !ok { - t.Fatalf("expected reply mime string, got %#v", reply["mime"]) - } + mime := mustString(t, reply["mime"]) if !strings.Contains(mime, "In-Reply-To: ") { t.Fatalf("expected reply mime to contain In-Reply-To header, got %q", mime) } } func TestAgentThreadAssistantSupportsFixtureDirConfig(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) indexPath := filepath.Join(t.TempDir(), "index.json") expectedFixtures := countFixtureEmails(t, filepath.Join(repoRoot, "testdata", "emails")) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_thread_assistant", "--mailcli-bin", mailcliBin, "--config", filepath.Join(repoRoot, "examples/config/fixtures-dir.yaml"), "--account", "fixtures", @@ -205,18 +167,12 @@ func TestAgentThreadAssistantSupportsFixtureDirConfig(t *testing.T) { "--sync-limit", strconv.Itoa(expectedFixtures), "--query", "invoice", ) - cmd.Dir = t.TempDir() - output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("thread agent example with fixture dir config failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) syncResult := mustMap(t, report["sync"]) if syncResult["indexed_count"] != float64(expectedFixtures) { t.Fatalf("expected fixture sync to index all repository fixtures, got %#v", syncResult["indexed_count"]) @@ -229,14 +185,11 @@ func TestAgentThreadAssistantSupportsFixtureDirConfig(t *testing.T) { } func TestAgentThreadAssistantBuildsLocalOnlyReplyDraftWithoutConfig(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) indexPath := agentThreadTestIndex(t) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_thread_assistant", "--mailcli-bin", mailcliBin, "--index", indexPath, "--skip-sync", @@ -249,11 +202,7 @@ func TestAgentThreadAssistantBuildsLocalOnlyReplyDraftWithoutConfig(t *testing.T t.Fatalf("thread agent local-only example failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) reply := mustMap(t, report["reply"]) draft := mustMap(t, reply["draft"]) if draft["reply_to_message_id"] != "" { @@ -263,10 +212,7 @@ func TestAgentThreadAssistantBuildsLocalOnlyReplyDraftWithoutConfig(t *testing.T t.Fatalf("expected local-only draft to avoid reply_to_id, got %#v", draft["reply_to_id"]) } - mime, ok := reply["mime"].(string) - if !ok { - t.Fatalf("expected reply mime string, got %#v", reply["mime"]) - } + mime := mustString(t, reply["mime"]) if !strings.Contains(mime, "In-Reply-To: ") { t.Fatalf("expected local-only reply mime to contain In-Reply-To header, got %q", mime) } @@ -280,51 +226,46 @@ func TestAgentThreadAssistantBuildsLocalOnlyReplyDraftWithoutConfig(t *testing.T } func TestAgentThreadAssistantReloadsLatestMessageWhenThreadLimitTruncates(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) indexPath := filepath.Join(t.TempDir(), "index.db") - { - store := mailindex.NewFileStore(indexPath) - for _, item := range []mailindex.IndexedMessage{ - { - Account: "demo", Mailbox: "INBOX", ID: "msg-root", - IndexedAt: "2026-03-27T08:00:00Z", - Message: schema.StandardMessage{ - ID: "msg-root", - Meta: schema.MessageMeta{ - Subject: "Project update", Date: "2026-03-27T08:00:00Z", - MessageID: "", - From: &schema.Address{Name: "Older Sender", Address: "older@example.com"}, - }, - Content: schema.Content{Snippet: "Initial update", BodyMD: "Initial update"}, + store := mailindex.NewFileStore(indexPath) + for _, item := range []mailindex.IndexedMessage{ + { + Account: "demo", Mailbox: "INBOX", ID: "msg-root", + IndexedAt: "2026-03-27T08:00:00Z", + Message: schema.StandardMessage{ + ID: "msg-root", + Meta: schema.MessageMeta{ + Subject: "Project update", Date: "2026-03-27T08:00:00Z", + MessageID: "", + From: &schema.Address{Name: "Older Sender", Address: "older@example.com"}, }, + Content: schema.Content{Snippet: "Initial update", BodyMD: "Initial update"}, }, - { - Account: "demo", Mailbox: "INBOX", ID: "msg-reply", - IndexedAt: "2026-03-27T09:00:00Z", - Message: schema.StandardMessage{ - ID: "msg-reply", - Meta: schema.MessageMeta{ - Subject: "Re: Project update", Date: "2026-03-27T09:00:00Z", - MessageID: "", - InReplyTo: "", - References: []string{""}, - From: &schema.Address{Name: "Latest Sender", Address: "latest@example.com"}, - }, - Content: schema.Content{Snippet: "Latest update", BodyMD: "Latest update"}, + }, + { + Account: "demo", Mailbox: "INBOX", ID: "msg-reply", + IndexedAt: "2026-03-27T09:00:00Z", + Message: schema.StandardMessage{ + ID: "msg-reply", + Meta: schema.MessageMeta{ + Subject: "Re: Project update", Date: "2026-03-27T09:00:00Z", + MessageID: "", + InReplyTo: "", + References: []string{""}, + From: &schema.Address{Name: "Latest Sender", Address: "latest@example.com"}, }, + Content: schema.Content{Snippet: "Latest update", BodyMD: "Latest update"}, }, - } { - if err := store.Upsert(item); err != nil { - t.Fatalf("agentThreadTestIndexWithReply: upsert failed: %v", err) - } + }, + } { + if err := store.Upsert(item); err != nil { + t.Fatalf("upsert failed: %v", err) } } - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_thread_assistant", "--mailcli-bin", mailcliBin, "--index", indexPath, "--skip-sync", @@ -338,11 +279,7 @@ func TestAgentThreadAssistantReloadsLatestMessageWhenThreadLimitTruncates(t *tes t.Fatalf("thread agent truncation example failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) latestMessage := mustMap(t, report["latest_message"]) if latestMessage["id"] != "msg-reply" { t.Fatalf("expected latest message to be reloaded, got %#v", latestMessage["id"]) @@ -366,36 +303,41 @@ func TestAgentThreadAssistantReloadsLatestMessageWhenThreadLimitTruncates(t *tes } func TestAgentThreadAssistantUsesExternalProvider(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) indexPath := agentThreadTestIndex(t) payloadPath := filepath.Join(t.TempDir(), "thread_payload.json") - providerPath := writeTempFile(t, "thread_provider.py", `import json -import os -import sys - -payload = json.load(sys.stdin) -with open(os.environ["THREAD_PROVIDER_PAYLOAD_PATH"], "w", encoding="utf-8") as fh: - json.dump(payload, fh) -latest = payload["latest_message"]["message"] -print(json.dumps({ - "decision": "draft_reply", - "summary": latest["content"]["snippet"], - "reply_text": "Handled by thread provider." -})) + providerPath := writeTempFile(t, "thread_provider.go", `package main + +import ( + "encoding/json" + "os" +) + +func main() { + var payload map[string]any + _ = json.NewDecoder(os.Stdin).Decode(&payload) + data, _ := json.Marshal(payload) + _ = os.WriteFile(os.Getenv("THREAD_PROVIDER_PAYLOAD_PATH"), data, 0o644) + latest := payload["latest_message"].(map[string]any)["message"].(map[string]any) + content := latest["content"].(map[string]any) + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "decision": "draft_reply", + "summary": content["snippet"], + "reply_text": "Handled by thread provider.", + }) +} `) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), + cmd := goRunExample(t, repoRoot, "agent_thread_assistant", "--mailcli-bin", mailcliBin, "--index", indexPath, "--skip-sync", "--thread-id", "", "--from-address", "support@nono.im", "--agent-provider", "external", - "--provider-command", python, + "--provider-command", "go", + "--provider-arg", "run", "--provider-arg", providerPath, ) cmd.Env = append(os.Environ(), "THREAD_PROVIDER_PAYLOAD_PATH="+payloadPath) @@ -404,11 +346,7 @@ print(json.dumps({ t.Fatalf("thread agent external provider failed: %v\n%s", err, string(output)) } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - + report := decodeObject(t, output) analysis := mustMap(t, report["analysis"]) if analysis["decision"] != "draft_reply" { t.Fatalf("expected external provider to request draft_reply, got %#v", analysis["decision"]) @@ -427,10 +365,7 @@ print(json.dumps({ if err != nil { t.Fatal(err) } - var payload map[string]any - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - t.Fatalf("expected payload json: %v", err) - } + payload := decodeObject(t, payloadBytes) selectionPayload := mustMap(t, payload["selection"]) if selectionPayload["thread_id"] != "" { t.Fatalf("expected selection thread id in payload, got %#v", selectionPayload["thread_id"]) @@ -438,483 +373,119 @@ print(json.dumps({ if payload["wants_reply"] != false { t.Fatalf("expected wants_reply false without explicit reply text, got %#v", payload["wants_reply"]) } - summaries := mustSlice(t, payload["thread_summaries"]) - if len(summaries) != 1 { - t.Fatalf("expected one thread summary in payload, got %#v", summaries) - } - threadMessages := mustSlice(t, payload["thread_messages"]) - if len(threadMessages) != 1 { - t.Fatalf("expected one thread message in payload, got %#v", threadMessages) - } -} - -func TestAgentThreadAssistantWorksWithTemplateExternalProvider(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - indexPath := agentThreadTestIndex(t) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), - "--mailcli-bin", mailcliBin, - "--index", indexPath, - "--skip-sync", - "--thread-id", "", - "--from-address", "support@nono.im", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", filepath.Join(repoRoot, "examples/providers/template_external_provider.py"), - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("thread agent template provider failed: %v\n%s", err, string(output)) - } - - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - - analysis := mustMap(t, report["analysis"]) - if analysis["provider"] != "external" { - t.Fatalf("expected external provider metadata, got %#v", analysis["provider"]) - } - if analysis["decision"] != "review" { - t.Fatalf("expected template provider to default to review, got %#v", analysis["decision"]) - } } -func TestAgentThreadAssistantTemplateProviderSupportsReplyBranch(t *testing.T) { - python := requirePython(t) +func TestTemplateExternalProviderBranches(t *testing.T) { repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) - indexPath := agentThreadTestIndex(t) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), - "--mailcli-bin", mailcliBin, - "--index", indexPath, - "--skip-sync", - "--thread-id", "", - "--from-address", "support@nono.im", - "--reply-text", "Please draft a reply.", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", filepath.Join(repoRoot, "examples/providers/template_external_provider.py"), - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("thread agent template provider reply branch failed: %v\n%s", err, string(output)) - } - - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - - analysis := mustMap(t, report["analysis"]) - if analysis["decision"] != "draft_reply" { - t.Fatalf("expected template provider to request draft reply, got %#v", analysis["decision"]) - } -} - -func TestAgentThreadAssistantRejectsInvalidExternalProviderResponse(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - indexPath := agentThreadTestIndex(t) - providerPath := writeTempFile(t, "thread_provider_invalid.py", `import json -print(json.dumps({"summary": "missing decision"})) -`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), - "--mailcli-bin", mailcliBin, - "--index", indexPath, - "--skip-sync", - "--thread-id", "", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected invalid thread provider response to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider response must include a non-empty decision") { - t.Fatalf("expected contract error, got %s", string(output)) - } -} - -func TestAgentThreadAssistantRejectsInvalidExternalProviderJSON(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - indexPath := agentThreadTestIndex(t) - providerPath := writeTempFile(t, "thread_provider_bad_json.py", `print("not-json")`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), - "--mailcli-bin", mailcliBin, - "--index", indexPath, - "--skip-sync", - "--thread-id", "", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected invalid thread provider json to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider returned invalid JSON") { - t.Fatalf("expected invalid json contract error, got %s", string(output)) - } -} - -func TestAgentThreadAssistantRejectsUnknownExternalDecision(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - indexPath := agentThreadTestIndex(t) - providerPath := writeTempFile(t, "thread_provider_unknown.py", `import json -print(json.dumps({"decision": "archive_now", "summary": "unsupported"})) -`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), - "--mailcli-bin", mailcliBin, - "--index", indexPath, - "--skip-sync", - "--thread-id", "", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected unknown thread provider decision to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider decision must be one of") { - t.Fatalf("expected decision enum error, got %s", string(output)) - } -} - -func TestAgentThreadAssistantRejectsInvalidReplyTextType(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - indexPath := agentThreadTestIndex(t) - providerPath := writeTempFile(t, "thread_provider_bad_reply_text.py", `import json -print(json.dumps({"decision": "draft_reply", "summary": "bad", "reply_text": 123})) -`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_thread_assistant.py"), - "--mailcli-bin", mailcliBin, - "--index", indexPath, - "--skip-sync", - "--thread-id", "", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected invalid thread provider reply_text to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider reply_text must be a string when present") { - t.Fatalf("expected reply_text contract error, got %s", string(output)) - } -} - -func TestAgentInboxAssistantUsesExternalProvider(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - providerPath := writeTempFile(t, "provider.py", `import json -import sys - -payload = json.load(sys.stdin) -message = payload["message"] -print(json.dumps({ - "decision": "draft_reply", - "summary": message["content"]["snippet"], - "reply_text": "Handled by external provider." -})) -`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), - "--from-address", "support@nono.im", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("agent example failed: %v\n%s", err, string(output)) - } - - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - - analysis := mustMap(t, report["analysis"]) - if analysis["decision"] != "draft_reply" { - t.Fatalf("expected provider-driven draft_reply decision, got %#v", analysis["decision"]) - } - if analysis["provider"] != "external" { - t.Fatalf("expected provider metadata, got %#v", analysis["provider"]) - } - - reply := mustMap(t, report["reply"]) - draft := mustMap(t, reply["draft"]) - if draft["body_text"] != "Handled by external provider." { - t.Fatalf("expected provider reply text, got %#v", draft["body_text"]) - } -} - -func TestAgentInboxAssistantRejectsInvalidExternalProviderResponse(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - providerPath := writeTempFile(t, "provider_invalid.py", `import json -print(json.dumps({"summary": "missing decision"})) -`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected invalid provider response to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider response must include a non-empty decision") { - t.Fatalf("expected contract error, got %s", string(output)) - } -} - -func TestAgentInboxAssistantRejectsInvalidExternalProviderJSON(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - providerPath := writeTempFile(t, "provider_bad_json.py", `print("not-json")`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected invalid provider json to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider returned invalid JSON") { - t.Fatalf("expected invalid json contract error, got %s", string(output)) - } -} - -func TestAgentInboxAssistantRejectsUnknownExternalDecision(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - providerPath := writeTempFile(t, "provider_unknown.py", `import json -print(json.dumps({"decision": "archive_now", "summary": "unsupported"})) -`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected unknown decision to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider decision must be one of") { - t.Fatalf("expected decision enum error, got %s", string(output)) - } -} - -func TestAgentInboxAssistantRejectsNonObjectExternalProviderJSON(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - providerPath := writeTempFile(t, "provider_array_json.py", `print("[]")`) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", providerPath, - ) - output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected non-object provider json to fail, got success: %s", string(output)) - } - if !strings.Contains(string(output), "external provider must return a JSON object") { - t.Fatalf("expected object contract error, got %s", string(output)) - } -} - -func TestAgentInboxAssistantWorksWithTemplateExternalProvider(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), - "--from-address", "support@nono.im", - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", filepath.Join(repoRoot, "examples/providers/template_external_provider.py"), - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("template provider failed: %v\n%s", err, string(output)) - } - - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - - analysis := mustMap(t, report["analysis"]) - if analysis["provider"] != "external" { - t.Fatalf("expected external provider metadata, got %#v", analysis["provider"]) - } - if analysis["decision"] != "review" { - t.Fatalf("expected template provider to default to review, got %#v", analysis["decision"]) - } -} - -func TestAgentInboxAssistantTemplateProviderCapturesVerificationCodes(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/verification.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", filepath.Join(repoRoot, "examples/providers/template_external_provider.py"), - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("template provider verification flow failed: %v\n%s", err, string(output)) - } - - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } - - analysis := mustMap(t, report["analysis"]) - if analysis["decision"] != "capture_code" { - t.Fatalf("expected template provider to capture code, got %#v", analysis["decision"]) - } - if !strings.Contains(mustString(t, analysis["summary"]), "expires in 600 seconds") { - t.Fatalf("expected verification summary to mention expiry, got %#v", analysis["summary"]) - } -} - -func TestAgentInboxAssistantTemplateProviderEscalatesBounceMail(t *testing.T) { - python := requirePython(t) - repoRoot := repoRoot(t) - mailcliBin := buildMailcliBinary(t, repoRoot) - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/bounce.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", filepath.Join(repoRoot, "examples/providers/template_external_provider.py"), - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("template provider bounce flow failed: %v\n%s", err, string(output)) + tests := []struct { + name string + email string + decision string + summary string + }{ + { + name: "verification", + email: "verification.eml", + decision: "capture_code", + summary: "expires in 600 seconds", + }, + { + name: "bounce", + email: "bounce.eml", + decision: "escalate_delivery_error", + summary: "Authentication credentials invalid", + }, + { + name: "unsubscribe", + email: "unsubscribe_mixed.eml", + decision: "review", + summary: "Subscription email with 2 unsubscribe action(s).", + }, } - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := []string{ + "--mailcli-bin", mailcliBin, + "--email", filepath.Join(repoRoot, "testdata/emails", tc.email), + "--agent-provider", "external", + } + args = append(args, templateProviderArgs(repoRoot)...) + cmd := goRunExample(t, repoRoot, "agent_inbox_assistant", args...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("template provider flow failed: %v\n%s", err, string(output)) + } - analysis := mustMap(t, report["analysis"]) - if analysis["decision"] != "escalate_delivery_error" { - t.Fatalf("expected template provider to escalate bounce mail, got %#v", analysis["decision"]) - } - if !strings.Contains(mustString(t, analysis["summary"]), "Authentication credentials invalid") { - t.Fatalf("expected bounce summary to include diagnostic code, got %#v", analysis["summary"]) + report := decodeObject(t, output) + analysis := mustMap(t, report["analysis"]) + if analysis["decision"] != tc.decision { + t.Fatalf("expected decision %s, got %#v", tc.decision, analysis["decision"]) + } + if !strings.Contains(mustString(t, analysis["summary"]), tc.summary) { + t.Fatalf("expected summary to contain %q, got %#v", tc.summary, analysis["summary"]) + } + }) } } -func TestAgentInboxAssistantTemplateProviderSummarizesUnsubscribeActions(t *testing.T) { - python := requirePython(t) +func TestAgentInboxAssistantExternalProviderContractErrors(t *testing.T) { repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/agent_inbox_assistant.py"), - "--mailcli-bin", mailcliBin, - "--email", filepath.Join(repoRoot, "testdata/emails/unsubscribe_mixed.eml"), - "--agent-provider", "external", - "--provider-command", python, - "--provider-arg", filepath.Join(repoRoot, "examples/providers/template_external_provider.py"), - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("template provider unsubscribe flow failed: %v\n%s", err, string(output)) - } - - var report map[string]any - if err := json.Unmarshal(output, &report); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) + tests := []struct { + name string + source string + wantOutput string + }{ + { + name: "missing decision", + source: `package main; import ("encoding/json"; "os"); func main(){ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"summary":"missing decision"}) }`, + wantOutput: "external provider response must include a non-empty decision", + }, + { + name: "bad json", + source: `package main; import "fmt"; func main(){ fmt.Print("not-json") }`, + wantOutput: "external provider returned invalid JSON", + }, + { + name: "unknown decision", + source: `package main; import ("encoding/json"; "os"); func main(){ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"decision":"archive_now","summary":"unsupported"}) }`, + wantOutput: "external provider decision must be one of", + }, + { + name: "non-object", + source: `package main; import "fmt"; func main(){ fmt.Print("[]") }`, + wantOutput: "external provider must return a JSON object", + }, + { + name: "bad reply_text", + source: `package main; import ("encoding/json"; "os"); func main(){ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"decision":"draft_reply","summary":"bad","reply_text":123}) }`, + wantOutput: "external provider reply_text must be a string when present", + }, } - analysis := mustMap(t, report["analysis"]) - if analysis["decision"] != "review" { - t.Fatalf("expected template provider to keep unsubscribe mail in review, got %#v", analysis["decision"]) - } - if mustString(t, analysis["summary"]) != "Subscription email with 2 unsubscribe action(s)." { - t.Fatalf("expected unsubscribe-aware summary, got %#v", analysis["summary"]) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + providerPath := writeTempFile(t, "provider.go", tc.source) + cmd := goRunExample(t, repoRoot, "agent_inbox_assistant", + "--mailcli-bin", mailcliBin, + "--email", filepath.Join(repoRoot, "testdata/emails/plaintext.eml"), + "--agent-provider", "external", + "--provider-command", "go", + "--provider-arg", "run", + "--provider-arg", providerPath, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected provider failure, got success: %s", string(output)) + } + if !strings.Contains(string(output), tc.wantOutput) { + t.Fatalf("expected %q, got %s", tc.wantOutput, string(output)) + } + }) } } @@ -1004,16 +575,13 @@ func TestOutboundPatternArtifactsCompile(t *testing.T) { } } -func TestRefreshLocalThreadDemoScript(t *testing.T) { - python := requirePython(t) +func TestRefreshLocalThreadDemoCommand(t *testing.T) { repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) outputDir := filepath.Join(t.TempDir(), "local-thread-demo") indexPath := filepath.Join(t.TempDir(), "index.json") - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/refresh_local_thread_demo.py"), + cmd := goRunExample(t, repoRoot, "refresh_local_thread_demo", "--mailcli-bin", mailcliBin, "--config", filepath.Join(repoRoot, "examples/config/fixtures-dir.yaml"), "--account", "fixtures", @@ -1023,7 +591,7 @@ func TestRefreshLocalThreadDemoScript(t *testing.T) { ) output, err := cmd.CombinedOutput() if err != nil { - t.Fatalf("refresh local thread demo script failed: %v\n%s", err, string(output)) + t.Fatalf("refresh local thread demo command failed: %v\n%s", err, string(output)) } for _, name := range []string{ @@ -1039,14 +607,7 @@ func TestRefreshLocalThreadDemoScript(t *testing.T) { } } - syncBytes, err := os.ReadFile(filepath.Join(outputDir, "sync.json")) - if err != nil { - t.Fatal(err) - } - var syncResult map[string]any - if err := json.Unmarshal(syncBytes, &syncResult); err != nil { - t.Fatalf("expected sync artifact json: %v", err) - } + syncResult := readJSONFile(t, filepath.Join(outputDir, "sync.json")) expectedFixtures := countFixtureEmails(t, filepath.Join(repoRoot, "testdata", "emails")) if syncResult["indexed_count"] != float64(expectedFixtures) { t.Fatalf("expected generated sync artifact to match fixture corpus count, got %#v", syncResult["indexed_count"]) @@ -1055,14 +616,7 @@ func TestRefreshLocalThreadDemoScript(t *testing.T) { t.Fatalf("expected generated sync artifact to start from a clean index, got %#v", syncResult["skipped_count"]) } - reportBytes, err := os.ReadFile(filepath.Join(outputDir, "agent-report.json")) - if err != nil { - t.Fatal(err) - } - var report map[string]any - if err := json.Unmarshal(reportBytes, &report); err != nil { - t.Fatalf("expected agent report json: %v", err) - } + report := readJSONFile(t, filepath.Join(outputDir, "agent-report.json")) reportSync := mustMap(t, report["sync"]) if reportSync["indexed_count"] != float64(expectedFixtures) { t.Fatalf("expected generated agent report sync stats to match fixture corpus count, got %#v", reportSync["indexed_count"]) @@ -1079,9 +633,6 @@ func TestRefreshLocalThreadDemoScript(t *testing.T) { if !strings.Contains(replyMime, "Message-ID: ") { t.Fatalf("expected generated reply mime to normalize message id, got %s", replyMime) } - if strings.Contains(replyMime, "mailcli.local>") && !strings.Contains(replyMime, "") { - t.Fatalf("expected generated reply mime to avoid runtime-specific message ids, got %s", replyMime) - } threadBytes, err := os.ReadFile(filepath.Join(outputDir, "thread.json")) if err != nil { @@ -1099,14 +650,11 @@ func TestRefreshLocalThreadDemoScript(t *testing.T) { } } -func TestRefreshLocalThreadDemoScriptCheckMode(t *testing.T) { - python := requirePython(t) +func TestRefreshLocalThreadDemoCommandCheckMode(t *testing.T) { repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/refresh_local_thread_demo.py"), + cmd := goRunExample(t, repoRoot, "refresh_local_thread_demo", "--mailcli-bin", mailcliBin, "--config", filepath.Join(repoRoot, "examples/config/fixtures-dir.yaml"), "--account", "fixtures", @@ -1124,8 +672,7 @@ func TestRefreshLocalThreadDemoScriptCheckMode(t *testing.T) { } } -func TestRefreshLocalThreadDemoScriptUsesSelectedDirAccountForDefaultSyncLimit(t *testing.T) { - python := requirePython(t) +func TestRefreshLocalThreadDemoCommandUsesSelectedDirAccountForDefaultSyncLimit(t *testing.T) { repoRoot := repoRoot(t) mailcliBin := buildMailcliBinary(t, repoRoot) outputDir := filepath.Join(t.TempDir(), "local-thread-demo") @@ -1142,9 +689,7 @@ func TestRefreshLocalThreadDemoScriptUsesSelectedDirAccountForDefaultSyncLimit(t configPath := writeTempFile(t, "fixtures-multi.yaml", "current_account: other\naccounts:\n - name: other\n driver: dir\n path: "+bogusRoot+"\n mailbox: INBOX\n - name: fixtures\n driver: dir\n path: "+filepath.Join(repoRoot, "testdata", "emails")+"\n mailbox: INBOX\n") - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/python/refresh_local_thread_demo.py"), + cmd := goRunExample(t, repoRoot, "refresh_local_thread_demo", "--mailcli-bin", mailcliBin, "--config", configPath, "--account", "fixtures", @@ -1154,17 +699,10 @@ func TestRefreshLocalThreadDemoScriptUsesSelectedDirAccountForDefaultSyncLimit(t ) output, err := cmd.CombinedOutput() if err != nil { - t.Fatalf("refresh local thread demo script with multi-account config failed: %v\n%s", err, string(output)) + t.Fatalf("refresh local thread demo command with multi-account config failed: %v\n%s", err, string(output)) } - syncBytes, err := os.ReadFile(filepath.Join(outputDir, "sync.json")) - if err != nil { - t.Fatal(err) - } - var syncResult map[string]any - if err := json.Unmarshal(syncBytes, &syncResult); err != nil { - t.Fatalf("expected sync artifact json: %v", err) - } + syncResult := readJSONFile(t, filepath.Join(outputDir, "sync.json")) expectedFixtures := countFixtureEmails(t, filepath.Join(repoRoot, "testdata", "emails")) if syncResult["indexed_count"] != float64(expectedFixtures) { t.Fatalf("expected selected account fixture corpus count, got %#v", syncResult["indexed_count"]) @@ -1172,13 +710,9 @@ func TestRefreshLocalThreadDemoScriptUsesSelectedDirAccountForDefaultSyncLimit(t } func TestOpenAIExternalProviderRequiresAPIKey(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/providers/openai_external_provider.py"), - ) + cmd := goRunExample(t, repoRoot, "providers/openai_external_provider") cmd.Stdin = strings.NewReader(`{"message":{"content":{"snippet":"hello"}},"source":{"mode":"email","value":"x"},"wants_reply":false}`) output, err := cmd.CombinedOutput() if err == nil { @@ -1190,69 +724,38 @@ func TestOpenAIExternalProviderRequiresAPIKey(t *testing.T) { } func TestOpenAIExternalProviderUsesResponsesAPIShape(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) + var request map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + t.Fatalf("expected /responses request, got %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Fatalf("expected bearer auth, got %q", r.Header.Get("Authorization")) + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"output_text":"{\"decision\":\"review\",\"summary\":\"stubbed openai provider\"}"}`)) + })) + defer server.Close() - stubDir := t.TempDir() - requestPath := filepath.Join(stubDir, "request.json") - stubModule := `import json -import os - -class _Response: - def __init__(self, output_text): - self.output_text = output_text - -class _Responses: - def create(self, **kwargs): - with open(os.environ["OPENAI_STUB_REQUEST_PATH"], "w", encoding="utf-8") as fh: - json.dump(kwargs, fh) - return _Response(json.dumps({ - "decision": "review", - "summary": "stubbed openai provider" - })) - -class OpenAI: - def __init__(self): - self.responses = _Responses() -` - stubModulePath := filepath.Join(stubDir, "openai.py") - if err := os.WriteFile(stubModulePath, []byte(stubModule), 0o644); err != nil { - t.Fatal(err) - } - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/providers/openai_external_provider.py"), - ) + cmd := goRunExample(t, repoRoot, "providers/openai_external_provider") cmd.Stdin = strings.NewReader(`{"message":{"content":{"snippet":"hello"}},"source":{"mode":"email","value":"x"},"wants_reply":false}`) cmd.Env = append(os.Environ(), "OPENAI_API_KEY=test-key", "OPENAI_MODEL=gpt-5-mini", - "PYTHONPATH="+stubDir, - "OPENAI_STUB_REQUEST_PATH="+requestPath, + "OPENAI_BASE_URL="+server.URL, ) - output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("openai provider failed: %v\n%s", err, string(output)) } - var result map[string]any - if err := json.Unmarshal(output, &result); err != nil { - t.Fatalf("expected json output: %v\n%s", err, string(output)) - } + result := decodeObject(t, output) if result["decision"] != "review" { t.Fatalf("expected stubbed decision, got %#v", result["decision"]) } - - requestBytes, err := os.ReadFile(requestPath) - if err != nil { - t.Fatal(err) - } - var request map[string]any - if err := json.Unmarshal(requestBytes, &request); err != nil { - t.Fatalf("expected request json: %v", err) - } if request["model"] != "gpt-5-mini" { t.Fatalf("expected OPENAI_MODEL to be used, got %#v", request["model"]) } @@ -1264,40 +767,17 @@ class OpenAI: } func TestOpenAIExternalProviderNormalizesThreadPayload(t *testing.T) { - python := requirePython(t) repoRoot := repoRoot(t) + var request map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"output_text":"{\"decision\":\"review\",\"summary\":\"stubbed openai provider\"}"}`)) + })) + defer server.Close() - stubDir := t.TempDir() - requestPath := filepath.Join(stubDir, "request.json") - stubModule := `import json -import os - -class _Response: - def __init__(self, output_text): - self.output_text = output_text - -class _Responses: - def create(self, **kwargs): - with open(os.environ["OPENAI_STUB_REQUEST_PATH"], "w", encoding="utf-8") as fh: - json.dump(kwargs, fh) - return _Response(json.dumps({ - "decision": "review", - "summary": "stubbed openai provider" - })) - -class OpenAI: - def __init__(self): - self.responses = _Responses() -` - stubModulePath := filepath.Join(stubDir, "openai.py") - if err := os.WriteFile(stubModulePath, []byte(stubModule), 0o644); err != nil { - t.Fatal(err) - } - - cmd := exec.Command( - python, - filepath.Join(repoRoot, "examples/providers/openai_external_provider.py"), - ) + cmd := goRunExample(t, repoRoot, "providers/openai_external_provider") cmd.Stdin = strings.NewReader(`{ "source": {"mode": "local_thread", "thread_id": ""}, "selection": {"thread_id": "", "last_message_id": "imap:uid:123"}, @@ -1314,35 +794,19 @@ class OpenAI: cmd.Env = append(os.Environ(), "OPENAI_API_KEY=test-key", "OPENAI_MODEL=gpt-5-mini", - "PYTHONPATH="+stubDir, - "OPENAI_STUB_REQUEST_PATH="+requestPath, + "OPENAI_BASE_URL="+server.URL, ) - output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("openai provider thread mode failed: %v\n%s", err, string(output)) } - requestBytes, err := os.ReadFile(requestPath) - if err != nil { - t.Fatal(err) - } - var request map[string]any - if err := json.Unmarshal(requestBytes, &request); err != nil { - t.Fatalf("expected request json: %v", err) - } input := mustSlice(t, request["input"]) user := mustMap(t, input[1]) content := mustSlice(t, user["content"]) part := mustMap(t, content[0]) - payloadText, ok := part["text"].(string) - if !ok { - t.Fatalf("expected payload text, got %#v", part["text"]) - } - var normalized map[string]any - if err := json.Unmarshal([]byte(payloadText), &normalized); err != nil { - t.Fatalf("expected normalized payload json: %v", err) - } + payloadText := mustString(t, part["text"]) + normalized := decodeObject(t, []byte(payloadText)) message := mustMap(t, normalized["message"]) codes := mustSlice(t, message["codes"]) if len(codes) != 1 { @@ -1350,15 +814,15 @@ class OpenAI: } } -func TestRepositoryProvidesLocalThreadDemoMaintenanceEntrypoints(t *testing.T) { +func TestRepositoryProvidesGoOnlyLocalThreadDemoMaintenanceEntrypoints(t *testing.T) { repoRoot := repoRoot(t) - makefilePath := filepath.Join(repoRoot, "Makefile") - makefileBytes, err := os.ReadFile(makefilePath) + makefileBytes, err := os.ReadFile(filepath.Join(repoRoot, "Makefile")) if err != nil { t.Fatalf("expected repository Makefile: %v", err) } makefile := string(makefileBytes) + bytecodeEnv := "PY" + "THON" + "DONTWRITEBYTECODE" if !strings.Contains(makefile, "demo-local-thread-refresh:") { t.Fatalf("expected Makefile to expose demo-local-thread-refresh target") } @@ -1368,12 +832,12 @@ func TestRepositoryProvidesLocalThreadDemoMaintenanceEntrypoints(t *testing.T) { if !strings.Contains(makefile, "MAILCLI_BIN ?= /tmp/mailcli") { t.Fatalf("expected Makefile to keep maintenance builds out of the repository root") } - if !strings.Contains(makefile, "PYTHONDONTWRITEBYTECODE=1 python3") { - t.Fatalf("expected Makefile to avoid creating __pycache__ during demo maintenance") + pythonToken := "py" + "thon" + if strings.Contains(strings.ToLower(makefile), pythonToken) || strings.Contains(makefile, bytecodeEnv) { + t.Fatalf("expected Makefile maintenance targets to avoid non-Go runtimes") } - workflowPath := filepath.Join(repoRoot, ".github", "workflows", "test.yml") - workflowBytes, err := os.ReadFile(workflowPath) + workflowBytes, err := os.ReadFile(filepath.Join(repoRoot, ".github", "workflows", "test.yml")) if err != nil { t.Fatalf("expected workflow file: %v", err) } @@ -1381,16 +845,28 @@ func TestRepositoryProvidesLocalThreadDemoMaintenanceEntrypoints(t *testing.T) { if !strings.Contains(workflow, "make demo-local-thread-check") { t.Fatalf("expected CI to run make demo-local-thread-check") } + compileToken := "py_" + "compile" + if strings.Contains(workflow, "setup-"+pythonToken) || strings.Contains(workflow, compileToken) { + t.Fatalf("expected CI to avoid non-Go setup and compile checks") + } } -func requirePython(t *testing.T) string { +func goRunExample(t *testing.T, repoRoot, example string, args ...string) *exec.Cmd { t.Helper() - python, err := exec.LookPath("python3") - if err != nil { - t.Skip("python3 not available") + examplePath := filepath.Join(repoRoot, "examples", "go", filepath.FromSlash(example)) + command := append([]string{"run", examplePath}, args...) + cmd := exec.Command("go", command...) + cmd.Dir = repoRoot + return cmd +} + +func templateProviderArgs(repoRoot string) []string { + return []string{ + "--provider-command", "go", + "--provider-arg", "run", + "--provider-arg", filepath.Join(repoRoot, "examples/go/providers/template_external_provider"), } - return python } func repoRoot(t *testing.T) string { @@ -1426,6 +902,26 @@ func writeTempFile(t *testing.T, name, content string) string { return path } +func decodeObject(t *testing.T, data []byte) map[string]any { + t.Helper() + + var report map[string]any + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("expected json object: %v\n%s", err, string(data)) + } + return report +} + +func readJSONFile(t *testing.T, path string) map[string]any { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return decodeObject(t, data) +} + func mustMap(t *testing.T, value any) map[string]any { t.Helper() @@ -1478,11 +974,9 @@ func countFixtureEmails(t *testing.T, root string) int { return count } -// agentThreadTestIndex creates a temporary SQLite index pre-seeded with a -// single 'msg-root' message in thread , which is the -// fixture thread_id used by the agent contract tests. func agentThreadTestIndex(t *testing.T) string { t.Helper() + path := filepath.Join(t.TempDir(), "index.db") store := mailindex.NewFileStore(path) err := store.Upsert(mailindex.IndexedMessage{ diff --git a/examples/go/agent_inbox_assistant/main.go b/examples/go/agent_inbox_assistant/main.go new file mode 100644 index 0000000..875ee51 --- /dev/null +++ b/examples/go/agent_inbox_assistant/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +type options struct { + mailcliBin string + email string + messageID string + config string + account string + replyText string + fromAddress string + fromName string + agentProvider string + providerCommand string + providerArgs multiFlag +} + +type multiFlag []string + +func (m *multiFlag) String() string { + return fmt.Sprint([]string(*m)) +} + +func (m *multiFlag) Set(value string) error { + *m = append(*m, value) + return nil +} + +func run() error { + opts, err := parseArgs() + if err != nil { + return err + } + + message, err := loadMessage(opts) + if err != nil { + return err + } + + analysis, err := analyzeWithProvider(message, opts) + if err != nil { + return err + } + + report := map[string]any{ + "tool": "mailcli-agent-example", + "source": buildSource(opts), + "message": message, + "analysis": analysis, + } + + replyText := opts.replyText + if replyText == "" { + replyText, _ = analysis["reply_text"].(string) + } + if replyText != "" { + if opts.fromAddress == "" { + return fmt.Errorf("--from-address is required when --reply-text is used") + } + draft, err := buildReplyDraft(message, opts) + if err != nil { + return err + } + draft["body_text"] = replyText + draftJSON, err := agent.MarshalCompact(draft) + if err != nil { + return err + } + mime, err := agent.RunCommand([]string{opts.mailcliBin, "reply", "--dry-run", "-"}, draftJSON) + if err != nil { + return err + } + report["reply"] = map[string]any{ + "mode": "dry_run", + "draft": draft, + "mime": mime, + } + analysis["decision"] = "draft_reply" + } + + return agent.WriteJSON(os.Stdout, report) +} + +func parseArgs() (options, error) { + var opts options + flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + flags.SetOutput(os.Stderr) + flags.StringVar(&opts.mailcliBin, "mailcli-bin", "mailcli", "path to the mailcli binary") + flags.StringVar(&opts.email, "email", "", "local .eml file to parse") + flags.StringVar(&opts.messageID, "message-id", "", "message id to fetch through mailcli get") + flags.StringVar(&opts.config, "config", "", "mailcli config path for inbox-backed commands") + flags.StringVar(&opts.account, "account", "", "mailcli account override") + flags.StringVar(&opts.replyText, "reply-text", "", "optional reply body text to compile with mailcli reply --dry-run") + flags.StringVar(&opts.fromAddress, "from-address", "", "from address to use for reply dry-run") + flags.StringVar(&opts.fromName, "from-name", "", "optional from display name for reply dry-run") + flags.StringVar(&opts.agentProvider, "agent-provider", "builtin", "analysis provider: builtin or external") + flags.StringVar(&opts.providerCommand, "provider-command", "", "external provider command") + flags.Var(&opts.providerArgs, "provider-arg", "repeatable argument for the external provider command") + if err := flags.Parse(os.Args[1:]); err != nil { + return options{}, err + } + + if (opts.email == "") == (opts.messageID == "") { + return options{}, fmt.Errorf("exactly one of --email or --message-id is required") + } + if opts.agentProvider != "builtin" && opts.agentProvider != "external" { + return options{}, fmt.Errorf("--agent-provider must be builtin or external") + } + if opts.agentProvider == "external" && opts.providerCommand == "" { + return options{}, fmt.Errorf("--provider-command is required when --agent-provider external is used") + } + return opts, nil +} + +func buildSource(opts options) map[string]any { + if opts.email != "" { + return map[string]any{ + "mode": "email", + "value": opts.email, + } + } + return map[string]any{ + "mode": "message_id", + "value": opts.messageID, + "config": opts.config, + "account": opts.account, + } +} + +func loadMessage(opts options) (map[string]any, error) { + var command []string + if opts.email != "" { + command = []string{opts.mailcliBin, "parse", "--format", "json", opts.email} + } else { + command = []string{opts.mailcliBin, "get", "--format", "json"} + if opts.config != "" { + command = append(command, "--config", opts.config) + } + if opts.account != "" { + command = append(command, "--account", opts.account) + } + command = append(command, opts.messageID) + } + + output, err := agent.RunCommand(command, "") + if err != nil { + return nil, err + } + var message map[string]any + if err := json.Unmarshal([]byte(output), &message); err != nil { + return nil, err + } + return message, nil +} + +func analyzeWithProvider(message map[string]any, opts options) (map[string]any, error) { + if opts.agentProvider == "external" { + payload := map[string]any{ + "source": buildSource(opts), + "message": message, + "wants_reply": opts.replyText != "", + } + payloadJSON, err := agent.MarshalCompact(payload) + if err != nil { + return nil, err + } + command := append([]string{opts.providerCommand}, []string(opts.providerArgs)...) + output, err := agent.RunCommand(command, payloadJSON) + if err != nil { + return nil, err + } + analysis, err := agent.ParseExternalAnalysis(output) + if err != nil { + return nil, err + } + if _, ok := analysis["provider"]; !ok { + analysis["provider"] = "external" + } + return analysis, nil + } + + analysis := agent.AnalyzeMessage(message, opts.replyText != "") + analysis["provider"] = "builtin" + return analysis, nil +} + +func buildReplyDraft(message map[string]any, opts options) (map[string]any, error) { + meta := agent.MapValue(message, "meta") + sender := agent.MapValue(meta, "from") + senderAddress := agent.StringValue(sender, "address") + if senderAddress == "" { + return nil, fmt.Errorf("message does not contain a sender address for reply drafting") + } + + references := agent.StringSlice(meta["references"]) + messageID := agent.StringValue(meta, "message_id") + if messageID != "" && !containsString(references, messageID) { + references = append(references, messageID) + } + + from := map[string]any{"address": opts.fromAddress} + if opts.fromName != "" { + from["name"] = opts.fromName + } + to := map[string]any{"address": senderAddress} + if name := agent.StringValue(sender, "name"); name != "" { + to["name"] = name + } + + draft := map[string]any{ + "from": from, + "to": []any{to}, + "body_text": opts.replyText, + "reply_to_message_id": messageID, + "references": references, + "subject": agent.StringValue(meta, "subject"), + } + if opts.account != "" { + draft["account"] = opts.account + } + return draft, nil +} + +func containsString(items []any, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} diff --git a/examples/go/agent_thread_assistant/main.go b/examples/go/agent_thread_assistant/main.go new file mode 100644 index 0000000..9824f0e --- /dev/null +++ b/examples/go/agent_thread_assistant/main.go @@ -0,0 +1,466 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +type options struct { + mailcliBin string + config string + account string + mailbox string + index string + query string + threadID string + syncLimit int + threadLimit int + threadMessageLimit int + skipSync bool + replyText string + fromAddress string + fromName string + agentProvider string + providerCommand string + providerArgs multiFlag +} + +type multiFlag []string + +func (m *multiFlag) String() string { + return fmt.Sprint([]string(*m)) +} + +func (m *multiFlag) Set(value string) error { + *m = append(*m, value) + return nil +} + +func run() error { + opts, err := parseArgs() + if err != nil { + return err + } + + var syncResult any + if !opts.skipSync { + syncResult, err = runSync(opts) + if err != nil { + return err + } + } + + threadSummaries, err := loadThreadSummaries(opts) + if err != nil { + return err + } + selection, err := selectThread(threadSummaries, opts) + if err != nil { + return err + } + threadMessages, err := loadThreadMessages(agent.StringValue(selection, "thread_id"), opts, opts.threadMessageLimit) + if err != nil { + return err + } + if len(threadMessages) == 0 { + return fmt.Errorf("selected thread did not return any local messages") + } + + threadMessages, latestMessage, err := ensureLatestMessage(selection, threadMessages, opts) + if err != nil { + return err + } + + analysis, err := analyzeWithProvider(selection, threadSummaries, threadMessages, latestMessage, opts) + if err != nil { + return err + } + + report := map[string]any{ + "tool": "mailcli-thread-agent-example", + "source": buildSource(opts), + "selection": selection, + "thread_summaries": threadSummaries, + "thread_messages": threadMessages, + "latest_message": latestMessage, + "analysis": analysis, + } + if syncResult != nil { + report["sync"] = syncResult + } + + replyText := opts.replyText + if replyText == "" { + replyText, _ = analysis["reply_text"].(string) + } + if replyText != "" { + if opts.fromAddress == "" { + return fmt.Errorf("--from-address is required when --reply-text is used") + } + draft, err := buildReplyDraft(selection, latestMessage, opts) + if err != nil { + return err + } + draft["body_text"] = replyText + mime, err := compileReplyDryRun(draft, opts) + if err != nil { + return err + } + report["reply"] = map[string]any{ + "mode": "dry_run", + "draft": draft, + "mime": mime, + } + analysis["decision"] = "draft_reply" + } + + return agent.WriteJSON(os.Stdout, report) +} + +func parseArgs() (options, error) { + var opts options + flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + flags.SetOutput(os.Stderr) + flags.StringVar(&opts.mailcliBin, "mailcli-bin", "mailcli", "path to the mailcli binary") + flags.StringVar(&opts.config, "config", "", "mailcli config path") + flags.StringVar(&opts.account, "account", "", "mailcli account override") + flags.StringVar(&opts.mailbox, "mailbox", "", "mailcli mailbox override") + flags.StringVar(&opts.index, "index", "", "local index path") + flags.StringVar(&opts.query, "query", "", "thread query used for selection") + flags.StringVar(&opts.threadID, "thread-id", "", "explicit thread id override") + flags.IntVar(&opts.syncLimit, "sync-limit", 10, "maximum messages to sync before thread selection") + flags.IntVar(&opts.threadLimit, "thread-limit", 10, "maximum thread summaries to load") + flags.IntVar(&opts.threadMessageLimit, "thread-message-limit", 50, "maximum local thread messages to load") + flags.BoolVar(&opts.skipSync, "skip-sync", false, "skip mailcli sync and use the existing local index") + flags.StringVar(&opts.replyText, "reply-text", "", "optional reply body text to compile with mailcli reply --dry-run") + flags.StringVar(&opts.fromAddress, "from-address", "", "from address to use for reply dry-run") + flags.StringVar(&opts.fromName, "from-name", "", "optional from display name for reply dry-run") + flags.StringVar(&opts.agentProvider, "agent-provider", "builtin", "analysis provider: builtin or external") + flags.StringVar(&opts.providerCommand, "provider-command", "", "external provider command") + flags.Var(&opts.providerArgs, "provider-arg", "repeatable argument for the external provider command") + if err := flags.Parse(os.Args[1:]); err != nil { + return options{}, err + } + + if opts.index == "" { + return options{}, fmt.Errorf("--index is required") + } + if !opts.skipSync && opts.config == "" { + return options{}, fmt.Errorf("--config is required unless --skip-sync is used") + } + if opts.agentProvider != "builtin" && opts.agentProvider != "external" { + return options{}, fmt.Errorf("--agent-provider must be builtin or external") + } + if opts.agentProvider == "external" && opts.providerCommand == "" { + return options{}, fmt.Errorf("--provider-command is required when --agent-provider external is used") + } + return opts, nil +} + +func buildSource(opts options) map[string]any { + return map[string]any{ + "mode": "local_thread", + "config": opts.config, + "account": opts.account, + "mailbox": opts.mailbox, + "index": opts.index, + "query": opts.query, + "thread_id": opts.threadID, + "skip_sync": opts.skipSync, + } +} + +func runSync(opts options) (any, error) { + command := []string{ + opts.mailcliBin, + "sync", + "--format", + "json", + "--config", + opts.config, + "--index", + opts.index, + "--limit", + fmt.Sprint(opts.syncLimit), + } + if opts.account != "" { + command = append(command, "--account", opts.account) + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + return agent.RunJSON(command, "") +} + +func loadThreadSummaries(opts options) ([]map[string]any, error) { + command := []string{ + opts.mailcliBin, + "threads", + "--format", + "json", + "--index", + opts.index, + "--limit", + fmt.Sprint(opts.threadLimit), + } + if opts.account != "" { + command = append(command, "--account", opts.account) + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + if opts.query != "" { + command = append(command, opts.query) + } + + value, err := agent.RunJSON(command, "") + if err != nil { + return nil, err + } + items, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("mailcli threads must return a JSON array") + } + return mapsFromSlice(items), nil +} + +func selectThread(threadSummaries []map[string]any, opts options) (map[string]any, error) { + explicitThreadID := strings.TrimSpace(opts.threadID) + if explicitThreadID != "" { + for _, item := range threadSummaries { + if agent.StringValue(item, "thread_id") == explicitThreadID { + result := cloneMap(item) + result["selection_strategy"] = "explicit_thread_id" + return result, nil + } + } + return map[string]any{ + "thread_id": explicitThreadID, + "selection_strategy": "explicit_thread_id", + }, nil + } + + if len(threadSummaries) == 0 { + return nil, fmt.Errorf("no local thread matched the current query") + } + + result := cloneMap(threadSummaries[0]) + result["selection_strategy"] = "top_thread" + return result, nil +} + +func loadThreadMessages(threadID string, opts options, limit int) ([]map[string]any, error) { + command := []string{ + opts.mailcliBin, + "thread", + "--format", + "json", + "--index", + opts.index, + } + if limit >= 0 { + command = append(command, "--limit", fmt.Sprint(limit)) + } + if opts.account != "" { + command = append(command, "--account", opts.account) + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + command = append(command, threadID) + + value, err := agent.RunJSON(command, "") + if err != nil { + return nil, err + } + items, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("mailcli thread must return a JSON array") + } + return mapsFromSlice(items), nil +} + +func ensureLatestMessage(selection map[string]any, threadMessages []map[string]any, opts options) ([]map[string]any, map[string]any, error) { + expectedLatestID := strings.TrimSpace(agent.StringValue(selection, "last_message_id")) + if expectedLatestID == "" { + return threadMessages, threadMessages[len(threadMessages)-1], nil + } + + for _, item := range threadMessages { + if agent.StringValue(item, "id") == expectedLatestID { + return threadMessages, item, nil + } + } + + reloadedMessages, err := loadThreadMessages(agent.StringValue(selection, "thread_id"), opts, 0) + if err != nil { + return nil, nil, err + } + if len(reloadedMessages) == 0 { + return nil, nil, fmt.Errorf("selected thread did not return any local messages after reload") + } + for _, item := range reloadedMessages { + if agent.StringValue(item, "id") == expectedLatestID { + return reloadedMessages, item, nil + } + } + return reloadedMessages, reloadedMessages[len(reloadedMessages)-1], nil +} + +func analyzeWithProvider(selection map[string]any, threadSummaries, threadMessages []map[string]any, latestMessage map[string]any, opts options) (map[string]any, error) { + if opts.agentProvider == "external" { + payload := map[string]any{ + "source": buildSource(opts), + "selection": selection, + "thread_summaries": threadSummaries, + "thread_messages": threadMessages, + "latest_message": latestMessage, + "wants_reply": opts.replyText != "", + } + payloadJSON, err := agent.MarshalCompact(payload) + if err != nil { + return nil, err + } + command := append([]string{opts.providerCommand}, []string(opts.providerArgs)...) + output, err := agent.RunCommand(command, payloadJSON) + if err != nil { + return nil, err + } + analysis, err := agent.ParseExternalAnalysis(output) + if err != nil { + return nil, err + } + if _, ok := analysis["provider"]; !ok { + analysis["provider"] = "external" + } + return analysis, nil + } + + analysis := analyzeThread(selection, latestMessage, opts.replyText != "") + analysis["provider"] = "builtin" + return analysis, nil +} + +func analyzeThread(selection, latestMessage map[string]any, wantsReply bool) map[string]any { + message := agent.MapValue(latestMessage, "message") + analysis := agent.AnalyzeMessage(message, wantsReply) + if analysis["summary"] == "" { + content := agent.MapValue(message, "content") + summary := agent.StringValue(content, "snippet") + if summary == "" { + summary = agent.StringValue(content, "body_md") + } + if summary == "" { + summary = agent.StringValue(selection, "last_message_preview") + } + analysis["summary"] = summary + } + return analysis +} + +func buildReplyDraft(selection, latestMessage map[string]any, opts options) (map[string]any, error) { + message := agent.MapValue(latestMessage, "message") + meta := agent.MapValue(message, "meta") + sender := agent.MapValue(meta, "from") + senderAddress := agent.StringValue(sender, "address") + if senderAddress == "" { + return nil, fmt.Errorf("latest thread message does not contain a sender address for reply drafting") + } + + from := map[string]any{"address": opts.fromAddress} + if opts.fromName != "" { + from["name"] = opts.fromName + } + to := map[string]any{"address": senderAddress} + if name := agent.StringValue(sender, "name"); name != "" { + to["name"] = name + } + + draft := map[string]any{ + "from": from, + "to": []any{to}, + "body_text": "", + } + + account := strings.TrimSpace(opts.account) + if account == "" { + account = agent.StringValue(latestMessage, "account") + } + if account != "" { + draft["account"] = account + } + + if opts.config != "" { + draft["reply_to_id"] = latestMessage["id"] + } else { + references := agent.StringSlice(meta["references"]) + messageID := agent.StringValue(meta, "message_id") + if messageID != "" && !containsString(references, messageID) { + references = append(references, messageID) + } + draft["reply_to_message_id"] = messageID + draft["references"] = references + subject := agent.StringValue(meta, "subject") + if subject == "" { + subject = agent.StringValue(selection, "subject") + } + draft["subject"] = subject + } + + return draft, nil +} + +func compileReplyDryRun(draft map[string]any, opts options) (string, error) { + command := []string{opts.mailcliBin, "reply", "--dry-run"} + if opts.config != "" { + command = append(command, "--config", opts.config) + } + if opts.account != "" { + command = append(command, "--account", opts.account) + } + command = append(command, "-") + + draftJSON, err := agent.MarshalCompact(draft) + if err != nil { + return "", err + } + return agent.RunCommand(command, draftJSON) +} + +func mapsFromSlice(items []any) []map[string]any { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if value, ok := item.(map[string]any); ok { + out = append(out, value) + } + } + return out +} + +func cloneMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func containsString(items []any, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} diff --git a/examples/go/parse_email/main.go b/examples/go/parse_email/main.go new file mode 100644 index 0000000..a9a4133 --- /dev/null +++ b/examples/go/parse_email/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: go run ./examples/go/parse_email -- ") + os.Exit(1) + } + + output, err := agent.RunCommand([]string{"mailcli", "parse", os.Args[1]}, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var message map[string]any + if err := json.Unmarshal([]byte(output), &message); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + content := agent.MapValue(message, "content") + fmt.Println(agent.StringValue(content, "body_md")) +} diff --git a/examples/go/providers/openai_external_provider/main.go b/examples/go/providers/openai_external_provider/main.go new file mode 100644 index 0000000..6a9667c --- /dev/null +++ b/examples/go/providers/openai_external_provider/main.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +const systemPrompt = `You are an email analysis provider for MailCLI. + +Return JSON that matches the required schema. +Choose exactly one decision from: +- review +- capture_code +- draft_reply +- escalate_delivery_error + +Use: +- capture_code when the message includes verification codes in message.codes +- escalate_delivery_error when the message includes error_context +- draft_reply only when wants_reply is true and a short safe reply is appropriate +- review otherwise + +Keep summary short. Only include reply_text when decision is draft_reply.` + +var outputSchema = map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "decision": map[string]any{ + "type": "string", + "enum": []string{ + "capture_code", + "draft_reply", + "escalate_delivery_error", + "review", + }, + }, + "summary": map[string]any{ + "type": "string", + }, + "reply_text": map[string]any{ + "type": "string", + }, + }, + "required": []string{"decision", "summary"}, +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) + if apiKey == "" { + return fmt.Errorf("OPENAI_API_KEY is required") + } + + var payload any + if err := json.NewDecoder(os.Stdin).Decode(&payload); err != nil { + return err + } + payload = normalizePayload(payload) + + result, err := callResponsesAPI(apiKey, payload) + if err != nil { + return err + } + if err := validateResult(result); err != nil { + return err + } + return agent.WriteJSON(os.Stdout, result) +} + +func callResponsesAPI(apiKey string, payload any) (map[string]any, error) { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + model := strings.TrimSpace(os.Getenv("OPENAI_MODEL")) + if model == "" { + model = "gpt-5-mini" + } + + requestBody := map[string]any{ + "model": model, + "input": []map[string]any{ + { + "role": "system", + "content": []map[string]any{ + {"type": "input_text", "text": systemPrompt}, + }, + }, + { + "role": "user", + "content": []map[string]any{ + {"type": "input_text", "text": string(payloadJSON)}, + }, + }, + }, + "text": map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "mailcli_agent_decision", + "schema": outputSchema, + "strict": true, + }, + }, + } + + body, err := json.Marshal(requestBody) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, responsesURL(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + message := strings.TrimSpace(string(responseBody)) + if message == "" { + message = resp.Status + } + return nil, fmt.Errorf("OpenAI provider request failed: %s", message) + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("OpenAI provider returned invalid response JSON: %w", err) + } + + outputText := extractOutputText(response) + if outputText == "" { + return nil, errors.New("OpenAI provider response did not include output text") + } + + var result map[string]any + if err := json.Unmarshal([]byte(outputText), &result); err != nil { + return nil, fmt.Errorf("OpenAI provider returned invalid JSON: %w", err) + } + return result, nil +} + +func responsesURL() string { + base := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/") + if base == "" { + base = "https://api.openai.com/v1" + } + if strings.HasSuffix(base, "/responses") { + return base + } + return base + "/responses" +} + +func normalizePayload(payload any) any { + object, ok := payload.(map[string]any) + if !ok { + return payload + } + + message, ok := object["message"].(map[string]any) + if ok && len(message) > 0 { + return payload + } + + latest, ok := object["latest_message"].(map[string]any) + if !ok { + return payload + } + latestMessage, ok := latest["message"].(map[string]any) + if !ok || len(latestMessage) == 0 { + return payload + } + + normalized := make(map[string]any, len(object)+1) + for key, value := range object { + normalized[key] = value + } + normalized["message"] = latestMessage + return normalized +} + +func extractOutputText(response map[string]any) string { + if outputText, ok := response["output_text"].(string); ok { + return outputText + } + + output, ok := response["output"].([]any) + if !ok { + return "" + } + for _, item := range output { + message, ok := item.(map[string]any) + if !ok { + continue + } + content, ok := message["content"].([]any) + if !ok { + continue + } + for _, part := range content { + contentPart, ok := part.(map[string]any) + if !ok { + continue + } + if text, ok := contentPart["text"].(string); ok && text != "" { + return text + } + } + } + return "" +} + +func validateResult(result map[string]any) error { + if err := agent.ValidateAnalysis(result, "OpenAI provider"); err != nil { + return err + } + summary, ok := result["summary"].(string) + if !ok || strings.TrimSpace(summary) == "" { + return fmt.Errorf("OpenAI provider response must include a non-empty summary") + } + return nil +} diff --git a/examples/go/providers/template_external_provider/main.go b/examples/go/providers/template_external_provider/main.go new file mode 100644 index 0000000..b99216a --- /dev/null +++ b/examples/go/providers/template_external_provider/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +func main() { + var payload map[string]any + if err := json.NewDecoder(os.Stdin).Decode(&payload); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if err := agent.WriteJSON(os.Stdout, agent.AnalyzeTemplatePayload(payload)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/examples/go/refresh_local_thread_demo/main.go b/examples/go/refresh_local_thread_demo/main.go new file mode 100644 index 0000000..94ee5e3 --- /dev/null +++ b/examples/go/refresh_local_thread_demo/main.go @@ -0,0 +1,500 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + + "github.com/nonozone/MailCli/examples/internal/agent" + "github.com/nonozone/MailCli/internal/config" +) + +const ( + canonicalIndexedAt = "2026-03-27T15:11:13Z" + canonicalMIMEDate = "Fri, 27 Mar 2026 15:11:13 +0000" + canonicalMessageID = "" + canonicalIndexPath = "/tmp/mailcli-fixtures-index.json" + canonicalConfig = "examples/config/fixtures-dir.yaml" +) + +type options struct { + mailcliBin string + config string + account string + index string + outputDir string + query string + mailbox string + syncLimit int + hasSyncLimit bool + threadLimit int + threadMessageLimit int + fromAddress string + replyText string + workdir string + check bool +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + opts, err := parseArgs() + if err != nil { + return err + } + + if opts.check { + return runCheckMode(opts) + } + + return generateArtifacts(opts, opts.outputDir) +} + +func parseArgs() (options, error) { + var opts options + flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + flags.SetOutput(os.Stderr) + flags.StringVar(&opts.mailcliBin, "mailcli-bin", "mailcli", "path to the mailcli binary") + flags.StringVar(&opts.config, "config", "", "mailcli config path") + flags.StringVar(&opts.account, "account", "", "mailcli account name") + flags.StringVar(&opts.index, "index", "", "local index path to build during refresh") + flags.StringVar(&opts.outputDir, "output-dir", "", "directory where artifacts should be written") + flags.StringVar(&opts.query, "query", "invoice", "thread query used for demo selection") + flags.StringVar(&opts.mailbox, "mailbox", "", "optional mailbox override") + flags.IntVar(&opts.syncLimit, "sync-limit", 0, "sync limit for the demo refresh") + flags.IntVar(&opts.threadLimit, "thread-limit", 10, "thread summary limit") + flags.IntVar(&opts.threadMessageLimit, "thread-message-limit", 50, "thread message limit") + flags.StringVar(&opts.fromAddress, "from-address", "support@nono.im", "from address for reply dry-run") + flags.StringVar(&opts.replyText, "reply-text", "Thanks, we have received the invoice notification.", "reply body text for the generated reply dry-run") + flags.StringVar(&opts.workdir, "workdir", "", "optional working directory for command execution") + flags.BoolVar(&opts.check, "check", false, "verify that the target artifact directory already matches freshly generated output") + if err := flags.Parse(os.Args[1:]); err != nil { + return options{}, err + } + flags.Visit(func(f *flag.Flag) { + if f.Name == "sync-limit" { + opts.hasSyncLimit = true + } + }) + + if opts.config == "" { + return options{}, fmt.Errorf("--config is required") + } + if opts.account == "" { + return options{}, fmt.Errorf("--account is required") + } + if opts.index == "" { + return options{}, fmt.Errorf("--index is required") + } + if opts.outputDir == "" { + return options{}, fmt.Errorf("--output-dir is required") + } + return opts, nil +} + +func generateArtifacts(opts options, outputDir string) error { + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return err + } + + resetIndexFile(opts.index) + syncResult, err := agent.RunJSONInDir(buildSyncCommand(opts), "", opts.workdir) + if err != nil { + return err + } + if err := writeJSON(filepath.Join(outputDir, "sync.json"), normalizeDemoJSON(syncResult)); err != nil { + return err + } + + threads, err := agent.RunJSONInDir(buildThreadsCommand(opts), "", opts.workdir) + if err != nil { + return err + } + if err := writeJSON(filepath.Join(outputDir, "threads.json"), threads); err != nil { + return err + } + threadItems, ok := threads.([]any) + if !ok || len(threadItems) == 0 { + return fmt.Errorf("mailcli threads returned no local thread summaries") + } + selection, ok := threadItems[0].(map[string]any) + if !ok { + return fmt.Errorf("selected thread is not a JSON object") + } + threadID := agent.StringValue(selection, "thread_id") + if threadID == "" { + return fmt.Errorf("selected thread is missing thread_id") + } + + threadMessages, err := agent.RunJSONInDir(buildThreadCommand(opts, threadID), "", opts.workdir) + if err != nil { + return err + } + if err := writeJSON(filepath.Join(outputDir, "thread.json"), normalizeDemoJSON(threadMessages)); err != nil { + return err + } + + resetIndexFile(opts.index) + report, err := agent.RunJSON(buildAgentCommand(opts), "") + if err != nil { + return err + } + report = normalizeDemoJSON(report) + reportMap, ok := report.(map[string]any) + if !ok { + return fmt.Errorf("agent report must be a JSON object") + } + + reply := agent.MapValue(reportMap, "reply") + if len(reply) == 0 { + return fmt.Errorf("agent report is missing reply section") + } + draft := agent.MapValue(reply, "draft") + if len(draft) == 0 { + return fmt.Errorf("agent report reply is missing draft object") + } + mime, ok := reply["mime"].(string) + if !ok { + return fmt.Errorf("agent report reply is missing mime output") + } + + normalizedMIME := normalizeReplyMIME(mime) + reply["mime"] = normalizedMIME + if err := writeJSON(filepath.Join(outputDir, "agent-report.json"), reportMap); err != nil { + return err + } + if err := writeJSON(filepath.Join(outputDir, "reply.draft.json"), draft); err != nil { + return err + } + return os.WriteFile(filepath.Join(outputDir, "reply.mime.txt"), []byte(strings.TrimRight(normalizedMIME, "\n")+"\n"), 0o644) +} + +func runCheckMode(opts options) error { + tempRoot, err := os.MkdirTemp("", "mailcli-local-thread-demo-") + if err != nil { + return err + } + defer os.RemoveAll(tempRoot) + + tempOpts := opts + tempOpts.outputDir = filepath.Join(tempRoot, "generated") + tempOpts.index = filepath.Join(tempRoot, "index.json") + tempOpts.check = false + if err := generateArtifacts(tempOpts, tempOpts.outputDir); err != nil { + return err + } + + mismatches, err := compareArtifactDirs(tempOpts.outputDir, opts.outputDir) + if err != nil { + return err + } + if len(mismatches) > 0 { + for _, mismatch := range mismatches { + fmt.Fprintln(os.Stderr, mismatch) + } + return fmt.Errorf("local-thread-demo artifacts are out of date") + } + + fmt.Println("local-thread-demo artifacts are up to date") + return nil +} + +func buildSyncCommand(opts options) []string { + command := []string{ + opts.mailcliBin, + "sync", + "--format", + "json", + "--config", + opts.config, + "--account", + opts.account, + "--index", + opts.index, + "--limit", + fmt.Sprint(resolveSyncLimit(opts)), + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + return command +} + +func buildThreadsCommand(opts options) []string { + command := []string{ + opts.mailcliBin, + "threads", + "--format", + "json", + "--index", + opts.index, + "--account", + opts.account, + "--limit", + fmt.Sprint(opts.threadLimit), + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + command = append(command, opts.query) + return command +} + +func buildThreadCommand(opts options, threadID string) []string { + command := []string{ + opts.mailcliBin, + "thread", + "--format", + "json", + "--index", + opts.index, + "--account", + opts.account, + "--limit", + fmt.Sprint(opts.threadMessageLimit), + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + command = append(command, threadID) + return command +} + +func buildAgentCommand(opts options) []string { + command := []string{ + "go", + "-C", + exampleRepoRoot(), + "run", + "./examples/go/agent_thread_assistant", + "--mailcli-bin", + resolveMaybePath(opts.mailcliBin, opts.workdir), + "--config", + resolvePath(opts.config, opts.workdir), + "--account", + opts.account, + "--index", + resolvePath(opts.index, opts.workdir), + "--sync-limit", + fmt.Sprint(resolveSyncLimit(opts)), + "--thread-limit", + fmt.Sprint(opts.threadLimit), + "--thread-message-limit", + fmt.Sprint(opts.threadMessageLimit), + "--query", + opts.query, + "--from-address", + opts.fromAddress, + "--reply-text", + opts.replyText, + } + if opts.mailbox != "" { + command = append(command, "--mailbox", opts.mailbox) + } + return command +} + +func exampleRepoRoot() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "." + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) +} + +func resolveSyncLimit(opts options) int { + if opts.hasSyncLimit { + return opts.syncLimit + } + + fixtureRoot, err := discoverFixtureRoot(opts.config, opts.account, opts.workdir) + if err != nil || fixtureRoot == "" { + return 20 + } + return countEMLFiles(fixtureRoot) +} + +func discoverFixtureRoot(configPath, accountName, workdir string) (string, error) { + configFile := resolvePath(configPath, workdir) + cfg, err := config.Load(configFile) + if err != nil { + return "", err + } + accountCfg, err := cfg.ResolveAccount(accountName) + if err != nil { + return "", err + } + if accountCfg.Driver != "dir" || accountCfg.Path == "" { + return "", nil + } + info, err := os.Stat(accountCfg.Path) + if err != nil || !info.IsDir() { + return "", err + } + return accountCfg.Path, nil +} + +func resolvePath(path, workdir string) string { + if filepath.IsAbs(path) { + return path + } + base := workdir + if base == "" { + base, _ = os.Getwd() + } + return filepath.Clean(filepath.Join(base, path)) +} + +func resolveMaybePath(path, workdir string) string { + if path == "" || filepath.IsAbs(path) { + return path + } + if !strings.ContainsAny(path, `/\`) && !strings.HasPrefix(path, ".") { + return path + } + return resolvePath(path, workdir) +} + +func countEMLFiles(root string) int { + count := 0 + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".eml") { + count++ + } + return nil + }) + return count +} + +func resetIndexFile(path string) { + _ = os.Remove(path) + if filepath.Ext(path) == ".json" { + _ = os.Remove(strings.TrimSuffix(path, ".json") + ".db") + } +} + +func normalizeDemoJSON(value any) any { + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + switch { + case key == "indexed_at": + if _, ok := item.(string); ok { + out[key] = canonicalIndexedAt + continue + } + case key == "index" || key == "index_path": + if _, ok := item.(string); ok { + out[key] = canonicalIndexPath + continue + } + case key == "config": + if _, ok := item.(string); ok { + out[key] = canonicalConfig + continue + } + } + out[key] = normalizeDemoJSON(item) + } + return out + case []any: + out := make([]any, len(typed)) + for i, item := range typed { + out[i] = normalizeDemoJSON(item) + } + return out + default: + return value + } +} + +func normalizeReplyMIME(mime string) string { + lines := strings.Split(mime, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "Message-ID: ") { + lines[i] = "Message-ID: " + canonicalMessageID + continue + } + if strings.HasPrefix(line, "Date: ") { + lines[i] = "Date: " + canonicalMIMEDate + } + } + return strings.Join(lines, "\n") +} + +func writeJSON(path string, value any) error { + var buf bytes.Buffer + if err := agent.WriteJSON(&buf, value); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o644) +} + +func compareArtifactDirs(generated, target string) ([]string, error) { + generatedEntries, err := os.ReadDir(generated) + if err != nil { + return nil, err + } + targetEntries, err := os.ReadDir(target) + if err != nil { + return nil, err + } + + generatedFiles := fileSet(generatedEntries) + targetFiles := fileSet(targetEntries) + mismatches := []string{} + + for name := range generatedFiles { + if !targetFiles[name] { + mismatches = append(mismatches, "missing artifact: "+filepath.Join(target, name)) + continue + } + equal, err := filesEqual(filepath.Join(generated, name), filepath.Join(target, name)) + if err != nil { + return nil, err + } + if !equal { + mismatches = append(mismatches, "artifact drift: "+filepath.Join(target, name)) + } + } + + for name := range targetFiles { + if !generatedFiles[name] { + mismatches = append(mismatches, "unexpected artifact: "+filepath.Join(target, name)) + } + } + return mismatches, nil +} + +func fileSet(entries []os.DirEntry) map[string]bool { + out := map[string]bool{} + for _, entry := range entries { + if !entry.IsDir() { + out[entry.Name()] = true + } + } + return out +} + +func filesEqual(left, right string) (bool, error) { + leftBytes, err := os.ReadFile(left) + if err != nil { + return false, err + } + rightBytes, err := os.ReadFile(right) + if err != nil { + return false, err + } + return reflect.DeepEqual(leftBytes, rightBytes), nil +} diff --git a/examples/go/reply_dry_run/main.go b/examples/go/reply_dry_run/main.go new file mode 100644 index 0000000..6a44850 --- /dev/null +++ b/examples/go/reply_dry_run/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "os" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: go run ./examples/go/reply_dry_run -- ") + os.Exit(1) + } + + output, err := agent.RunCommand([]string{"mailcli", "reply", "--dry-run", os.Args[1]}, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Print(output) +} diff --git a/examples/go/watch_reply_agent/main.go b/examples/go/watch_reply_agent/main.go new file mode 100644 index 0000000..47762dc --- /dev/null +++ b/examples/go/watch_reply_agent/main.go @@ -0,0 +1,225 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/nonozone/MailCli/examples/internal/agent" +) + +type options struct { + mailcliBin string + account string + fromAddress string + fromName string + autoSend bool + dryRun bool + draftReplies bool + providerCommand string + providerArgs multiFlag +} + +type multiFlag []string + +func (m *multiFlag) String() string { + return fmt.Sprint([]string(*m)) +} + +func (m *multiFlag) Set(value string) error { + *m = append(*m, value) + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + opts := parseArgs() + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if err := handleLine(line, opts); err != nil { + fmt.Fprintf(os.Stderr, "[agent] %v\n", err) + } + } + return scanner.Err() +} + +func parseArgs() options { + var opts options + flag.StringVar(&opts.mailcliBin, "mailcli-bin", "mailcli", "path to the mailcli binary") + flag.StringVar(&opts.account, "account", os.Getenv("MAILCLI_ACCOUNT"), "account name passed to mailcli reply") + flag.StringVar(&opts.fromAddress, "from-address", os.Getenv("MAILCLI_FROM_ADDRESS"), "from address for reply drafting") + flag.StringVar(&opts.fromName, "from-name", os.Getenv("MAILCLI_FROM_NAME"), "optional from display name") + flag.BoolVar(&opts.autoSend, "auto-send", os.Getenv("MAILCLI_AUTO_SEND") == "1", "send replies instead of printing draft JSON") + flag.BoolVar(&opts.dryRun, "dry-run", os.Getenv("MAILCLI_DRY_RUN") == "1", "compile MIME without sending") + flag.BoolVar(&opts.draftReplies, "draft-replies", false, "ask the provider to draft replies for reply-worthy messages") + flag.StringVar(&opts.providerCommand, "provider-command", "", "optional external provider command") + flag.Var(&opts.providerArgs, "provider-arg", "repeatable argument for the external provider command") + flag.Parse() + return opts +} + +func handleLine(line string, opts options) error { + var event map[string]any + if err := json.Unmarshal([]byte(line), &event); err != nil { + return nil + } + + switch event["event"] { + case "watching": + fmt.Fprintf(os.Stderr, "[agent] monitoring %v/%v\n", event["account"], event["mailbox"]) + case "new_message": + message := agent.MapValue(event, "message") + subject := agent.StringValue(agent.MapValue(message, "meta"), "subject") + if subject == "" { + subject = "(no subject)" + } + fmt.Fprintf(os.Stderr, "[agent] new message: %s\n", subject) + return handleMessage(message, opts) + case "error": + fmt.Fprintf(os.Stderr, "[agent] error: %v\n", event["error"]) + } + return nil +} + +func handleMessage(message map[string]any, opts options) error { + analysis, err := analyze(message, opts) + if err != nil { + return err + } + replyText, _ := analysis["reply_text"].(string) + if analysis["decision"] != "draft_reply" || replyText == "" { + fmt.Fprintf(os.Stderr, "[agent] skip: %v\n", analysis["summary"]) + return nil + } + if opts.fromAddress == "" { + return fmt.Errorf("--from-address is required to compile reply drafts") + } + + draft, err := buildReplyDraft(message, opts, replyText) + if err != nil { + return err + } + if opts.autoSend || opts.dryRun { + return executeReply(draft, opts) + } + + return agent.WriteJSON(os.Stdout, map[string]any{ + "ts": time.Now().UTC().Format(time.RFC3339), + "in_reply_to": draft["reply_to_message_id"], + "subject": draft["subject"], + "draft_body_text": replyText, + "analysis": analysis, + }) +} + +func analyze(message map[string]any, opts options) (map[string]any, error) { + if opts.providerCommand == "" { + analysis := agent.AnalyzeTemplatePayload(map[string]any{ + "message": message, + "wants_reply": opts.draftReplies, + }) + analysis["provider"] = "builtin" + return analysis, nil + } + + payload := map[string]any{ + "message": message, + "wants_reply": opts.draftReplies, + } + payloadJSON, err := agent.MarshalCompact(payload) + if err != nil { + return nil, err + } + command := append([]string{opts.providerCommand}, []string(opts.providerArgs)...) + output, err := agent.RunCommand(command, payloadJSON) + if err != nil { + return nil, err + } + analysis, err := agent.ParseExternalAnalysis(output) + if err != nil { + return nil, err + } + analysis["provider"] = "external" + return analysis, nil +} + +func buildReplyDraft(message map[string]any, opts options, replyText string) (map[string]any, error) { + meta := agent.MapValue(message, "meta") + sender := agent.MapValue(meta, "from") + senderAddress := agent.StringValue(sender, "address") + if senderAddress == "" { + return nil, fmt.Errorf("message does not contain a sender address for reply drafting") + } + + messageID := agent.StringValue(meta, "message_id") + references := agent.StringSlice(meta["references"]) + if messageID != "" && !containsString(references, messageID) { + references = append(references, messageID) + } + + from := map[string]any{"address": opts.fromAddress} + if opts.fromName != "" { + from["name"] = opts.fromName + } + to := map[string]any{"address": senderAddress} + if name := agent.StringValue(sender, "name"); name != "" { + to["name"] = name + } + + draft := map[string]any{ + "from": from, + "to": []any{to}, + "reply_to_message_id": messageID, + "references": references, + "subject": agent.StringValue(meta, "subject"), + "body_text": replyText, + } + if opts.account != "" { + draft["account"] = opts.account + } + return draft, nil +} + +func executeReply(draft map[string]any, opts options) error { + draftJSON, err := agent.MarshalCompact(draft) + if err != nil { + return err + } + command := []string{opts.mailcliBin, "reply"} + if opts.dryRun { + command = append(command, "--dry-run") + } + if opts.account != "" { + command = append(command, "--account", opts.account) + } + command = append(command, "-") + output, err := agent.RunCommand(command, draftJSON) + if err != nil { + return err + } + fmt.Fprint(os.Stderr, output) + return nil +} + +func containsString(items []any, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} diff --git a/examples/internal/agent/agent.go b/examples/internal/agent/agent.go new file mode 100644 index 0000000..8cd51e2 --- /dev/null +++ b/examples/internal/agent/agent.go @@ -0,0 +1,267 @@ +package agent + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "sort" + "strings" +) + +var allowedDecisions = map[string]bool{ + "review": true, + "capture_code": true, + "draft_reply": true, + "escalate_delivery_error": true, +} + +func WriteJSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func RunCommand(command []string, stdin string) (string, error) { + return RunCommandInDir(command, stdin, "") +} + +func RunCommandInDir(command []string, stdin, cwd string) (string, error) { + if len(command) == 0 || strings.TrimSpace(command[0]) == "" { + return "", errors.New("command is required") + } + + cmd := exec.Command(command[0], command[1:]...) + if cwd != "" { + cmd.Dir = cwd + } + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = strings.TrimSpace(stdout.String()) + } + if message == "" { + message = err.Error() + } + return "", errors.New(message) + } + return stdout.String(), nil +} + +func RunJSON(command []string, stdin string) (any, error) { + return RunJSONInDir(command, stdin, "") +} + +func RunJSONInDir(command []string, stdin, cwd string) (any, error) { + output, err := RunCommandInDir(command, stdin, cwd) + if err != nil { + return nil, err + } + + var value any + if err := json.Unmarshal([]byte(output), &value); err != nil { + return nil, fmt.Errorf("expected JSON output from %s: %w", strings.Join(command, " "), err) + } + return value, nil +} + +func ParseExternalAnalysis(output string) (map[string]any, error) { + var value any + if err := json.Unmarshal([]byte(output), &value); err != nil { + return nil, errors.New("external provider returned invalid JSON") + } + + analysis, ok := value.(map[string]any) + if !ok { + return nil, errors.New("external provider must return a JSON object") + } + + if err := ValidateAnalysis(analysis, "external provider"); err != nil { + return nil, err + } + return analysis, nil +} + +func ValidateAnalysis(analysis map[string]any, label string) error { + rawDecision, ok := analysis["decision"].(string) + decision := strings.TrimSpace(rawDecision) + if !ok || decision == "" { + return fmt.Errorf("%s response must include a non-empty decision", label) + } + if !allowedDecisions[decision] { + allowed := make([]string, 0, len(allowedDecisions)) + for decision := range allowedDecisions { + allowed = append(allowed, decision) + } + sort.Strings(allowed) + return fmt.Errorf("%s decision must be one of: %s", label, strings.Join(allowed, ", ")) + } + + if replyText, ok := analysis["reply_text"]; ok && replyText != nil { + if _, ok := replyText.(string); !ok { + return fmt.Errorf("%s reply_text must be a string when present", label) + } + } + return nil +} + +func AnalyzeMessage(message map[string]any, wantsReply bool) map[string]any { + content := MapValue(message, "content") + snippet := StringValue(content, "snippet") + if snippet == "" { + snippet = StringValue(content, "body_md") + } + + codes := SliceValue(message, "codes") + if len(codes) > 0 { + summary := fmt.Sprintf("Verification email with %d code(s).", len(codes)) + if first, ok := codes[0].(map[string]any); ok { + if expires, ok := first["expires_in_seconds"]; ok && expires != nil { + summary += fmt.Sprintf(" First code expires in %s seconds.", JSONNumberString(expires)) + } + } + return map[string]any{ + "decision": "capture_code", + "summary": summary, + } + } + + errorContext := MapValue(message, "error_context") + if len(errorContext) > 0 { + summary := StringValue(errorContext, "diagnostic_code") + if summary == "" { + summary = StringValue(errorContext, "status_code") + } + if summary == "" { + summary = "Delivery error detected." + } + return map[string]any{ + "decision": "escalate_delivery_error", + "summary": summary, + } + } + + if wantsReply { + return map[string]any{ + "decision": "draft_reply", + "summary": snippet, + } + } + + return map[string]any{ + "decision": "review", + "summary": snippet, + } +} + +func AnalyzeTemplatePayload(payload map[string]any) map[string]any { + message := MapValue(payload, "message") + if len(message) == 0 { + latest := MapValue(payload, "latest_message") + message = MapValue(latest, "message") + } + + wantsReply, _ := payload["wants_reply"].(bool) + actions := SliceValue(message, "actions") + unsubscribeCount := 0 + for _, item := range actions { + action, ok := item.(map[string]any) + if ok && StringValue(action, "type") == "unsubscribe" { + unsubscribeCount++ + } + } + + if unsubscribeCount > 0 && !wantsReply { + return map[string]any{ + "decision": "review", + "summary": fmt.Sprintf("Subscription email with %d unsubscribe action(s).", unsubscribeCount), + } + } + + analysis := AnalyzeMessage(message, wantsReply) + if wantsReply && analysis["decision"] == "draft_reply" { + analysis["reply_text"] = "Thanks for your email." + } + return analysis +} + +func MapValue(parent map[string]any, key string) map[string]any { + if parent == nil { + return map[string]any{} + } + if value, ok := parent[key].(map[string]any); ok { + return value + } + return map[string]any{} +} + +func SliceValue(parent map[string]any, key string) []any { + if parent == nil { + return nil + } + if value, ok := parent[key].([]any); ok { + return value + } + return nil +} + +func StringValue(parent map[string]any, key string) string { + if parent == nil { + return "" + } + value, ok := parent[key].(string) + if !ok { + return "" + } + return value +} + +func StringSlice(value any) []any { + items, ok := value.([]any) + if !ok { + return nil + } + out := make([]any, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out +} + +func JSONNumberString(value any) string { + switch v := value.(type) { + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%g", v) + case json.Number: + return v.String() + case string: + return v + default: + return fmt.Sprint(v) + } +} + +func MarshalCompact(value any) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/examples/providers/openai_external_provider.py b/examples/providers/openai_external_provider.py deleted file mode 100644 index 9545297..0000000 --- a/examples/providers/openai_external_provider.py +++ /dev/null @@ -1,158 +0,0 @@ -import json -import os -import sys -from typing import Any - -ALLOWED_DECISIONS = { - "review", - "capture_code", - "draft_reply", - "escalate_delivery_error", -} - -SYSTEM_PROMPT = """You are an email analysis provider for MailCLI. - -Return JSON that matches the required schema. -Choose exactly one decision from: -- review -- capture_code -- draft_reply -- escalate_delivery_error - -Use: -- capture_code when the message includes verification codes in message.codes -- escalate_delivery_error when the message includes error_context -- draft_reply only when wants_reply is true and a short safe reply is appropriate -- review otherwise - -Keep summary short. Only include reply_text when decision is draft_reply. -""" - -OUTPUT_SCHEMA: dict[str, Any] = { - "type": "object", - "additionalProperties": False, - "properties": { - "decision": { - "type": "string", - "enum": sorted(ALLOWED_DECISIONS), - }, - "summary": { - "type": "string", - }, - "reply_text": { - "type": "string", - }, - }, - "required": ["decision", "summary"], -} - - -def main() -> int: - api_key = os.environ.get("OPENAI_API_KEY", "").strip() - if not api_key: - print("OPENAI_API_KEY is required", file=sys.stderr) - return 2 - - try: - from openai import OpenAI - except ImportError: - print("openai package is required. Install it with: pip install openai", file=sys.stderr) - return 2 - - payload = normalize_payload(json.load(sys.stdin)) - model = os.environ.get("OPENAI_MODEL", "gpt-5-mini").strip() or "gpt-5-mini" - - client = OpenAI() - response = client.responses.create( - model=model, - input=[ - { - "role": "system", - "content": [ - { - "type": "input_text", - "text": SYSTEM_PROMPT, - } - ], - }, - { - "role": "user", - "content": [ - { - "type": "input_text", - "text": json.dumps(payload, ensure_ascii=False), - } - ], - }, - ], - text={ - "format": { - "type": "json_schema", - "name": "mailcli_agent_decision", - "schema": OUTPUT_SCHEMA, - "strict": True, - } - }, - ) - - try: - result = json.loads(response.output_text) - except json.JSONDecodeError as exc: - print(f"OpenAI provider returned invalid JSON: {exc}", file=sys.stderr) - return 1 - - validation_error = validate_result(result) - if validation_error: - print(validation_error, file=sys.stderr) - return 1 - - json.dump(result, sys.stdout, ensure_ascii=False) - sys.stdout.write("\n") - return 0 - - -def normalize_payload(payload: Any) -> Any: - if not isinstance(payload, dict): - return payload - - message = payload.get("message") - if isinstance(message, dict) and message: - return payload - - latest = payload.get("latest_message") - if not isinstance(latest, dict): - return payload - - latest_message = latest.get("message") - if not isinstance(latest_message, dict) or not latest_message: - return payload - - normalized = dict(payload) - normalized["message"] = latest_message - return normalized - - -def validate_result(result: Any) -> str | None: - if not isinstance(result, dict): - return "OpenAI provider must return a JSON object" - - decision = result.get("decision") - if not isinstance(decision, str) or not decision.strip(): - return "OpenAI provider response must include a non-empty decision" - if decision not in ALLOWED_DECISIONS: - allowed = ", ".join(sorted(ALLOWED_DECISIONS)) - return f"OpenAI provider decision must be one of: {allowed}" - - summary = result.get("summary") - if not isinstance(summary, str) or not summary.strip(): - return "OpenAI provider response must include a non-empty summary" - - reply_text = result.get("reply_text") - if reply_text is not None and not isinstance(reply_text, str): - return "OpenAI provider reply_text must be a string when present" - - return None - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/providers/template_external_provider.py b/examples/providers/template_external_provider.py deleted file mode 100644 index 45851df..0000000 --- a/examples/providers/template_external_provider.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -import sys -from typing import Any - - -def main() -> int: - payload = json.load(sys.stdin) - result = analyze(payload) - json.dump(result, sys.stdout, ensure_ascii=False) - sys.stdout.write("\n") - return 0 - - -def analyze(payload: dict[str, Any]) -> dict[str, Any]: - message = payload.get("message") or {} - if not message: - latest = payload.get("latest_message") or {} - if isinstance(latest, dict): - message = latest.get("message") or {} - content = message.get("content", {}) - actions = message.get("actions") or [] - codes = message.get("codes") or [] - error_context = message.get("error_context") - wants_reply = bool(payload.get("wants_reply")) - - if codes: - first_code = codes[0] - summary = f"Verification email with {len(codes)} code(s)." - if first_code.get("expires_in_seconds"): - summary += f" First code expires in {first_code['expires_in_seconds']} seconds." - return { - "decision": "capture_code", - "summary": summary, - } - - if error_context: - return { - "decision": "escalate_delivery_error", - "summary": error_context.get("diagnostic_code") or error_context.get("status_code") or "Delivery error detected.", - } - - unsubscribe_actions = [ - action - for action in actions - if isinstance(action, dict) and action.get("type") == "unsubscribe" - ] - if unsubscribe_actions and not wants_reply: - return { - "decision": "review", - "summary": f"Subscription email with {len(unsubscribe_actions)} unsubscribe action(s).", - } - - if wants_reply: - return { - "decision": "draft_reply", - "summary": content.get("snippet") or content.get("body_md") or "", - "reply_text": "Thanks for your email.", - } - - return { - "decision": "review", - "summary": content.get("snippet") or content.get("body_md") or "", - } - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/python/__pycache__/provider_contract.cpython-314.pyc b/examples/python/__pycache__/provider_contract.cpython-314.pyc deleted file mode 100644 index 3a5b3cf17178468ceb1364378f7862bc1c427a75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1904 zcmahKO-$QX_}Pw=l%#<`A<(A7D+L-s8`iODW2h5LpnzxqLun6DBiDWqBgeL$Ux1(< zqG^Zjv~?Fa?8sr2I<1Eucc`>#+A$+YAdg8%JM}P$4Wv%nW$!t0f<~)-(tGdo_y6|GbL>5NC;N?j_Z*y+$6Jd2D+T#CfF0IGeLDvOS6_V?_Ov*u0Ad z%1PLwp*xWe=l-DER^pHzPm<4Cp2<(!zfC#RBO(Qy zQc;8gPnJ`v>m;QjxNxEbGqtiflFs%vDdu3c%qEjQ<8tPiw9{q!Jy_N2tumKLfKib4 zxw0bbTF5f<%*eVaFzqtyh zD(!bI(Mop#;beDf7u?N)z8i&P9|b0RBI;Vd=h-YPGA8>mt1>G1h`R_>CU$1M7p`H5 zbV(E3`zlo415^Z6Uy(Fb5hmxRQ>pd7(~`Nog{$I%% zx~5{gfn=&%Skh&PXs2y%31Z}@7*B1Rc6elJYWmJ-GLsxlOw3J8r{;)xL)C4sWgrZd zm>1EhXZt16iGrnCHV4;a)JSPQNz;r~sGuhDWl^ynfdUFpZp4(31v74@WV%!gptlIu znJvE)qzDdKj=#n1w?sCEz8czGEVpz&^dI{{u%0aCI8KX z#>f{BK7X(|S#Intj_(Iri5RyVxV#;>{LT1xsmH0^{^9NZVMpaoIdr$=zq{va+=*P- z@m<+#Zrg}GihVt{(=}L*zE^I3|DlfxUKo46`dlq_%#=g3CI9R}xML&wD7vYY!+pic zJzsd+cX4a^>G|*Hc6&y)dqy1Fx67f0l7HbK&{CZK>$nR9n|1@8KLrzoNobJD?zG5oeq$ zV+Uo)BtHU3=CuOt2()BLqhnfunz{l}Ap>dSV?3fm%J$-dNiL1^qd@J1;uQQbKy)Bh sTH_zi8A@J*H&8dB(Xne$K@ugy`~m{Mg0??Bd@EZ5;a5#$*gKm40up+xbN~PV diff --git a/examples/python/agent_inbox_assistant.py b/examples/python/agent_inbox_assistant.py deleted file mode 100644 index 3dec2cb..0000000 --- a/examples/python/agent_inbox_assistant.py +++ /dev/null @@ -1,194 +0,0 @@ -import argparse -import json -import subprocess -import sys -from typing import Any - -from provider_contract import parse_external_analysis - - -def main() -> int: - args = parse_args() - - message = load_message(args) - analysis = analyze_with_provider(message, args) - report: dict[str, Any] = { - "tool": "mailcli-agent-example", - "source": build_source(args), - "message": message, - "analysis": analysis, - } - - reply_text = args.reply_text or analysis.get("reply_text") - if reply_text: - if not args.from_address: - print("--from-address is required when --reply-text is used", file=sys.stderr) - return 2 - - draft = build_reply_draft(message, args) - draft["body_text"] = reply_text - mime = run_mailcli( - [args.mailcli_bin, "reply", "--dry-run", "-"], - stdin=json.dumps(draft), - ) - report["reply"] = { - "mode": "dry_run", - "draft": draft, - "mime": mime, - } - report["analysis"]["decision"] = "draft_reply" - - json.dump(report, sys.stdout, indent=2, ensure_ascii=False) - sys.stdout.write("\n") - return 0 - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Minimal agent-style MailCLI workflow example.", - ) - parser.add_argument("--mailcli-bin", default="mailcli", help="path to the mailcli binary") - parser.add_argument("--email", help="local .eml file to parse") - parser.add_argument("--message-id", help="message id to fetch through mailcli get") - parser.add_argument("--config", help="mailcli config path for inbox-backed commands") - parser.add_argument("--account", help="mailcli account override") - parser.add_argument("--reply-text", help="optional reply body text to compile with mailcli reply --dry-run") - parser.add_argument("--from-address", help="from address to use for reply dry-run") - parser.add_argument("--from-name", help="optional from display name for reply dry-run") - parser.add_argument("--agent-provider", choices=["builtin", "external"], default="builtin", help="analysis provider") - parser.add_argument("--provider-command", help="external provider command") - parser.add_argument("--provider-arg", action="append", default=[], help="repeatable argument for the external provider command") - - args = parser.parse_args() - if bool(args.email) == bool(args.message_id): - parser.error("exactly one of --email or --message-id is required") - if args.agent_provider == "external" and not args.provider_command: - parser.error("--provider-command is required when --agent-provider external is used") - return args - - -def build_source(args: argparse.Namespace) -> dict[str, Any]: - if args.email: - return {"mode": "email", "value": args.email} - return { - "mode": "message_id", - "value": args.message_id, - "config": args.config, - "account": args.account, - } - - -def load_message(args: argparse.Namespace) -> dict[str, Any]: - if args.email: - output = run_mailcli([args.mailcli_bin, "parse", "--format", "json", args.email]) - else: - command = [args.mailcli_bin, "get", "--format", "json"] - if args.config: - command.extend(["--config", args.config]) - if args.account: - command.extend(["--account", args.account]) - command.append(args.message_id) - output = run_mailcli(command) - return json.loads(output) - - -def run_mailcli(command: list[str], stdin: str | None = None) -> str: - result = subprocess.run( - command, - input=stdin, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - message = result.stderr.strip() or result.stdout.strip() or "mailcli command failed" - raise SystemExit(message) - return result.stdout - - -def analyze_message(message: dict[str, Any], wants_reply: bool) -> dict[str, Any]: - content = message.get("content", {}) - snippet = content.get("snippet") or content.get("body_md") or "" - codes = message.get("codes") or [] - error_context = message.get("error_context") - - if codes: - first_code = codes[0] - summary = f"Verification email with {len(codes)} code(s)." - if first_code.get("expires_in_seconds"): - summary += f" First code expires in {first_code['expires_in_seconds']} seconds." - return { - "decision": "capture_code", - "summary": summary, - } - - if error_context: - return { - "decision": "escalate_delivery_error", - "summary": error_context.get("diagnostic_code") or error_context.get("status_code") or "Delivery error detected.", - } - - if wants_reply: - return { - "decision": "draft_reply", - "summary": snippet, - } - - return { - "decision": "review", - "summary": snippet, - } - - -def analyze_with_provider(message: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: - if args.agent_provider == "external": - payload = { - "source": build_source(args), - "message": message, - "wants_reply": bool(args.reply_text), - } - output = run_mailcli( - [args.provider_command, *args.provider_arg], - stdin=json.dumps(payload), - ) - analysis = parse_external_analysis(output) - analysis.setdefault("provider", "external") - return analysis - - analysis = analyze_message(message, wants_reply=bool(args.reply_text)) - analysis.setdefault("provider", "builtin") - return analysis - - -def build_reply_draft(message: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: - meta = message.get("meta", {}) - sender = meta.get("from") or {} - sender_address = sender.get("address") - if not sender_address: - raise SystemExit("message does not contain a sender address for reply drafting") - - references = list(meta.get("references") or []) - message_id = meta.get("message_id") - if message_id and message_id not in references: - references.append(message_id) - - draft: dict[str, Any] = { - "from": {"address": args.from_address}, - "to": [{"address": sender_address}], - "body_text": args.reply_text or "", - "reply_to_message_id": message_id, - "references": references, - "subject": meta.get("subject") or "", - } - if sender.get("name"): - draft["to"][0]["name"] = sender["name"] - if args.from_name: - draft["from"]["name"] = args.from_name - if args.account: - draft["account"] = args.account - - return draft - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/python/agent_thread_assistant.py b/examples/python/agent_thread_assistant.py deleted file mode 100644 index 8b67f10..0000000 --- a/examples/python/agent_thread_assistant.py +++ /dev/null @@ -1,340 +0,0 @@ -import argparse -import json -import subprocess -import sys -from typing import Any - -from provider_contract import parse_external_analysis - - -def main() -> int: - args = parse_args() - - sync_result = None - if not args.skip_sync: - sync_result = run_sync(args) - - thread_summaries = load_thread_summaries(args) - selection = select_thread(thread_summaries, args) - thread_messages = load_thread_messages(selection["thread_id"], args, args.thread_message_limit) - if not thread_messages: - raise SystemExit("selected thread did not return any local messages") - - thread_messages, latest_message = ensure_latest_message(selection, thread_messages, args) - analysis = analyze_with_provider(selection, thread_summaries, thread_messages, latest_message, args) - - report: dict[str, Any] = { - "tool": "mailcli-thread-agent-example", - "source": build_source(args), - "selection": selection, - "thread_summaries": thread_summaries, - "thread_messages": thread_messages, - "latest_message": latest_message, - "analysis": analysis, - } - if sync_result is not None: - report["sync"] = sync_result - - reply_text = args.reply_text or analysis.get("reply_text") - if reply_text: - if not args.from_address: - print("--from-address is required when --reply-text is used", file=sys.stderr) - return 2 - - draft = build_reply_draft(selection, latest_message, args) - draft["body_text"] = reply_text - mime = compile_reply_dry_run(draft, args) - report["reply"] = { - "mode": "dry_run", - "draft": draft, - "mime": mime, - } - report["analysis"]["decision"] = "draft_reply" - - json.dump(report, sys.stdout, indent=2, ensure_ascii=False) - sys.stdout.write("\n") - return 0 - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Thread-aware MailCLI agent workflow example.", - ) - parser.add_argument("--mailcli-bin", default="mailcli", help="path to the mailcli binary") - parser.add_argument("--config", help="mailcli config path") - parser.add_argument("--account", help="mailcli account override") - parser.add_argument("--mailbox", help="mailcli mailbox override") - parser.add_argument("--index", required=True, help="local index path") - parser.add_argument("--query", help="thread query used for selection") - parser.add_argument("--thread-id", help="explicit thread id override") - parser.add_argument("--sync-limit", type=int, default=10, help="maximum messages to sync before thread selection") - parser.add_argument("--thread-limit", type=int, default=10, help="maximum thread summaries to load") - parser.add_argument("--thread-message-limit", type=int, default=50, help="maximum local thread messages to load") - parser.add_argument("--skip-sync", action="store_true", help="skip mailcli sync and use the existing local index") - parser.add_argument("--reply-text", help="optional reply body text to compile with mailcli reply --dry-run") - parser.add_argument("--from-address", help="from address to use for reply dry-run") - parser.add_argument("--from-name", help="optional from display name for reply dry-run") - parser.add_argument("--agent-provider", choices=["builtin", "external"], default="builtin", help="analysis provider") - parser.add_argument("--provider-command", help="external provider command") - parser.add_argument("--provider-arg", action="append", default=[], help="repeatable argument for the external provider command") - - args = parser.parse_args() - if not args.skip_sync and not args.config: - parser.error("--config is required unless --skip-sync is used") - if args.agent_provider == "external" and not args.provider_command: - parser.error("--provider-command is required when --agent-provider external is used") - return args - - -def build_source(args: argparse.Namespace) -> dict[str, Any]: - return { - "mode": "local_thread", - "config": args.config, - "account": args.account, - "mailbox": args.mailbox, - "index": args.index, - "query": args.query, - "thread_id": args.thread_id, - "skip_sync": args.skip_sync, - } - - -def run_sync(args: argparse.Namespace) -> dict[str, Any]: - command = [ - args.mailcli_bin, - "sync", - "--format", - "json", - "--config", - args.config, - "--index", - args.index, - "--limit", - str(args.sync_limit), - ] - if args.account: - command.extend(["--account", args.account]) - if args.mailbox: - command.extend(["--mailbox", args.mailbox]) - return run_mailcli_json(command) - - -def load_thread_summaries(args: argparse.Namespace) -> list[dict[str, Any]]: - command = [ - args.mailcli_bin, - "threads", - "--format", - "json", - "--index", - args.index, - "--limit", - str(args.thread_limit), - ] - if args.account: - command.extend(["--account", args.account]) - if args.mailbox: - command.extend(["--mailbox", args.mailbox]) - if args.query: - command.append(args.query) - - output = run_mailcli_json(command) - if not isinstance(output, list): - raise SystemExit("mailcli threads must return a JSON array") - return output - - -def select_thread(thread_summaries: list[dict[str, Any]], args: argparse.Namespace) -> dict[str, Any]: - explicit_thread_id = (args.thread_id or "").strip() - if explicit_thread_id: - for item in thread_summaries: - if item.get("thread_id") == explicit_thread_id: - result = dict(item) - result["selection_strategy"] = "explicit_thread_id" - return result - return { - "thread_id": explicit_thread_id, - "selection_strategy": "explicit_thread_id", - } - - if not thread_summaries: - raise SystemExit("no local thread matched the current query") - - result = dict(thread_summaries[0]) - result["selection_strategy"] = "top_thread" - return result - - -def load_thread_messages(thread_id: str, args: argparse.Namespace, limit: int | None) -> list[dict[str, Any]]: - command = [ - args.mailcli_bin, - "thread", - "--format", - "json", - "--index", - args.index, - ] - if limit is not None: - command.extend(["--limit", str(limit)]) - if args.account: - command.extend(["--account", args.account]) - if args.mailbox: - command.extend(["--mailbox", args.mailbox]) - command.append(thread_id) - - output = run_mailcli_json(command) - if not isinstance(output, list): - raise SystemExit("mailcli thread must return a JSON array") - return output - - -def ensure_latest_message( - selection: dict[str, Any], - thread_messages: list[dict[str, Any]], - args: argparse.Namespace, -) -> tuple[list[dict[str, Any]], dict[str, Any]]: - expected_latest_id = (selection.get("last_message_id") or "").strip() - if not expected_latest_id: - return thread_messages, thread_messages[-1] - - for item in thread_messages: - if item.get("id") == expected_latest_id: - return thread_messages, item - - reloaded_messages = load_thread_messages(selection["thread_id"], args, 0) - if not reloaded_messages: - raise SystemExit("selected thread did not return any local messages after reload") - - for item in reloaded_messages: - if item.get("id") == expected_latest_id: - return reloaded_messages, item - - return reloaded_messages, reloaded_messages[-1] - - -def analyze_thread(selection: dict[str, Any], thread_messages: list[dict[str, Any]], wants_reply: bool) -> dict[str, Any]: - latest = thread_messages[-1] - message = latest.get("message", {}) - content = message.get("content", {}) - codes = message.get("codes") or [] - error_context = message.get("error_context") - - if codes: - return { - "decision": "capture_code", - "summary": f"Latest thread message contains {len(codes)} extracted code(s).", - } - - if error_context: - return { - "decision": "escalate_delivery_error", - "summary": error_context.get("diagnostic_code") or error_context.get("status_code") or "Delivery error detected.", - } - - if wants_reply: - return { - "decision": "draft_reply", - "summary": content.get("snippet") or content.get("body_md") or selection.get("last_message_preview") or "", - } - - return { - "decision": "review", - "summary": content.get("snippet") or content.get("body_md") or selection.get("last_message_preview") or "", - } - - -def analyze_with_provider( - selection: dict[str, Any], - thread_summaries: list[dict[str, Any]], - thread_messages: list[dict[str, Any]], - latest_message: dict[str, Any], - args: argparse.Namespace, -) -> dict[str, Any]: - if args.agent_provider == "external": - payload = { - "source": build_source(args), - "selection": selection, - "thread_summaries": thread_summaries, - "thread_messages": thread_messages, - "latest_message": latest_message, - "wants_reply": bool(args.reply_text), - } - output = run_mailcli( - [args.provider_command, *args.provider_arg], - stdin=json.dumps(payload), - ) - analysis = parse_external_analysis(output) - analysis.setdefault("provider", "external") - return analysis - - analysis = analyze_thread(selection, thread_messages, wants_reply=bool(args.reply_text)) - analysis.setdefault("provider", "builtin") - return analysis - - -def build_reply_draft(selection: dict[str, Any], latest_message: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: - message = latest_message.get("message", {}) - meta = message.get("meta", {}) - sender = meta.get("from") or {} - sender_address = sender.get("address") - if not sender_address: - raise SystemExit("latest thread message does not contain a sender address for reply drafting") - - draft: dict[str, Any] = { - "from": {"address": args.from_address}, - "to": [{"address": sender_address}], - "body_text": "", - } - if args.from_name: - draft["from"]["name"] = args.from_name - if sender.get("name"): - draft["to"][0]["name"] = sender["name"] - - account = (args.account or latest_message.get("account") or "").strip() - if account: - draft["account"] = account - - if args.config: - draft["reply_to_id"] = latest_message.get("id") - else: - references = list(meta.get("references") or []) - message_id = meta.get("message_id") - if message_id and message_id not in references: - references.append(message_id) - draft["reply_to_message_id"] = message_id - draft["references"] = references - draft["subject"] = meta.get("subject") or selection.get("subject") or "" - - return draft - - -def compile_reply_dry_run(draft: dict[str, Any], args: argparse.Namespace) -> str: - command = [args.mailcli_bin, "reply", "--dry-run"] - if args.config: - command.extend(["--config", args.config]) - if args.account: - command.extend(["--account", args.account]) - command.append("-") - return run_mailcli(command, stdin=json.dumps(draft)) - - -def run_mailcli_json(command: list[str], stdin: str | None = None) -> Any: - output = run_mailcli(command, stdin=stdin) - return json.loads(output) - - -def run_mailcli(command: list[str], stdin: str | None = None) -> str: - result = subprocess.run( - command, - input=stdin, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - message = result.stderr.strip() or result.stdout.strip() or "mailcli command failed" - raise SystemExit(message) - return result.stdout - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/python/parse_email.py b/examples/python/parse_email.py deleted file mode 100644 index 28ed9ea..0000000 --- a/examples/python/parse_email.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import subprocess -import sys - - -def main() -> int: - if len(sys.argv) != 2: - print("usage: python parse_email.py ", file=sys.stderr) - return 1 - - result = subprocess.run( - ["mailcli", "parse", sys.argv[1]], - capture_output=True, - text=True, - check=True, - ) - - message = json.loads(result.stdout) - print(message["content"]["body_md"]) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/python/provider_contract.py b/examples/python/provider_contract.py deleted file mode 100644 index 251b5a6..0000000 --- a/examples/python/provider_contract.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -from typing import Any - -ALLOWED_DECISIONS = { - "review", - "capture_code", - "draft_reply", - "escalate_delivery_error", -} - - -def parse_external_analysis(output: str) -> dict[str, Any]: - try: - analysis = json.loads(output) - except json.JSONDecodeError as exc: - raise SystemExit("external provider returned invalid JSON") from exc - - if not isinstance(analysis, dict): - raise SystemExit("external provider must return a JSON object") - - decision = analysis.get("decision") - if not isinstance(decision, str) or not decision.strip(): - raise SystemExit("external provider response must include a non-empty decision") - if decision not in ALLOWED_DECISIONS: - allowed = ", ".join(sorted(ALLOWED_DECISIONS)) - raise SystemExit(f"external provider decision must be one of: {allowed}") - - reply_text = analysis.get("reply_text") - if reply_text is not None and not isinstance(reply_text, str): - raise SystemExit("external provider reply_text must be a string when present") - - return analysis diff --git a/examples/python/refresh_local_thread_demo.py b/examples/python/refresh_local_thread_demo.py deleted file mode 100644 index dce8067..0000000 --- a/examples/python/refresh_local_thread_demo.py +++ /dev/null @@ -1,433 +0,0 @@ -import argparse -import filecmp -import json -import os -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import Any - -CANONICAL_INDEXED_AT = "2026-03-27T15:11:13Z" -CANONICAL_MIME_DATE = "Fri, 27 Mar 2026 15:11:13 +0000" -CANONICAL_MESSAGE_ID = "" -CANONICAL_INDEX_PATH = "/tmp/mailcli-fixtures-index.json" -CANONICAL_CONFIG_PATH = "examples/config/fixtures-dir.yaml" - - -def main() -> int: - args = parse_args() - if args.check: - return run_check_mode(args) - - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - generate_artifacts(args, output_dir) - return 0 - - -def generate_artifacts(args: argparse.Namespace, output_dir: Path) -> None: - output_dir.mkdir(parents=True, exist_ok=True) - - reset_index_file(args.index) - sync_result = run_mailcli_json( - build_sync_command(args), - cwd=args.workdir, - ) - write_json(output_dir / "sync.json", normalize_demo_json(sync_result)) - - threads = run_mailcli_json( - build_threads_command(args), - cwd=args.workdir, - ) - write_json(output_dir / "threads.json", threads) - if not isinstance(threads, list) or not threads: - raise SystemExit("mailcli threads returned no local thread summaries") - - selection = threads[0] - thread_id = selection.get("thread_id") - if not isinstance(thread_id, str) or not thread_id: - raise SystemExit("selected thread is missing thread_id") - - thread_messages = run_mailcli_json( - build_thread_command(args, thread_id), - cwd=args.workdir, - ) - write_json(output_dir / "thread.json", normalize_demo_json(thread_messages)) - - reset_index_file(args.index) - report = run_python_json( - build_agent_command(args), - cwd=args.workdir, - ) - report = normalize_demo_json(report) - write_json(output_dir / "agent-report.json", report) - - reply = report.get("reply") - if not isinstance(reply, dict): - raise SystemExit("agent report is missing reply section") - - draft = reply.get("draft") - if not isinstance(draft, dict): - raise SystemExit("agent report reply is missing draft object") - write_json(output_dir / "reply.draft.json", draft) - - mime = reply.get("mime") - if not isinstance(mime, str): - raise SystemExit("agent report reply is missing mime output") - normalized_mime = normalize_reply_mime(mime) - reply["mime"] = normalized_mime - write_json(output_dir / "agent-report.json", report) - write_text(output_dir / "reply.mime.txt", normalized_mime.rstrip() + "\n") - - -def run_check_mode(args: argparse.Namespace) -> int: - with tempfile.TemporaryDirectory(prefix="mailcli-local-thread-demo-") as tmpdir: - temp_output_dir = Path(tmpdir) / "generated" - temp_index_path = str(Path(tmpdir) / "index.json") - temp_args = argparse.Namespace(**vars(args)) - temp_args.output_dir = str(temp_output_dir) - temp_args.index = temp_index_path - temp_args.check = False - generate_artifacts(temp_args, temp_output_dir) - - target_dir = Path(args.output_dir) - mismatches = compare_artifact_dirs(temp_output_dir, target_dir) - if mismatches: - for mismatch in mismatches: - print(mismatch, file=sys.stderr) - return 1 - - print("local-thread-demo artifacts are up to date") - return 0 - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Refresh the stored local-thread-demo artifacts from the current fixture corpus.", - ) - parser.add_argument("--mailcli-bin", default="mailcli", help="path to the mailcli binary") - parser.add_argument("--config", required=True, help="mailcli config path") - parser.add_argument("--account", required=True, help="mailcli account name") - parser.add_argument("--index", required=True, help="local index path to build during refresh") - parser.add_argument("--output-dir", required=True, help="directory where artifacts should be written") - parser.add_argument("--query", default="invoice", help="thread query used for demo selection") - parser.add_argument("--mailbox", help="optional mailbox override") - parser.add_argument( - "--sync-limit", - type=int, - help="sync limit for the demo refresh; defaults to the full fixture corpus when the config points to a local dir driver", - ) - parser.add_argument("--thread-limit", type=int, default=10, help="thread summary limit") - parser.add_argument("--thread-message-limit", type=int, default=50, help="thread message limit") - parser.add_argument("--from-address", default="support@nono.im", help="from address for reply dry-run") - parser.add_argument( - "--reply-text", - default="Thanks, we have received the invoice notification.", - help="reply body text for the generated reply dry-run", - ) - parser.add_argument( - "--workdir", - help="optional working directory for command execution", - ) - parser.add_argument( - "--check", - action="store_true", - help="verify that the target artifact directory already matches freshly generated output", - ) - return parser.parse_args() - - -def build_sync_command(args: argparse.Namespace) -> list[str]: - sync_limit = resolve_sync_limit(args) - command = [ - args.mailcli_bin, - "sync", - "--format", - "json", - "--config", - args.config, - "--account", - args.account, - "--index", - args.index, - "--limit", - str(sync_limit), - ] - if args.mailbox: - command.extend(["--mailbox", args.mailbox]) - return command - - -def build_threads_command(args: argparse.Namespace) -> list[str]: - command = [ - args.mailcli_bin, - "threads", - "--format", - "json", - "--index", - args.index, - "--account", - args.account, - "--limit", - str(args.thread_limit), - args.query, - ] - if args.mailbox: - command[6:6] = ["--mailbox", args.mailbox] - return command - - -def build_thread_command(args: argparse.Namespace, thread_id: str) -> list[str]: - command = [ - args.mailcli_bin, - "thread", - "--format", - "json", - "--index", - args.index, - "--account", - args.account, - "--limit", - str(args.thread_message_limit), - thread_id, - ] - if args.mailbox: - command[6:6] = ["--mailbox", args.mailbox] - return command - - -def build_agent_command(args: argparse.Namespace) -> list[str]: - sync_limit = resolve_sync_limit(args) - command = [ - sys.executable, - str(Path(__file__).with_name("agent_thread_assistant.py")), - "--mailcli-bin", - args.mailcli_bin, - "--config", - args.config, - "--account", - args.account, - "--index", - args.index, - "--sync-limit", - str(sync_limit), - "--thread-limit", - str(args.thread_limit), - "--thread-message-limit", - str(args.thread_message_limit), - "--query", - args.query, - "--from-address", - args.from_address, - "--reply-text", - args.reply_text, - ] - if args.mailbox: - command.extend(["--mailbox", args.mailbox]) - return command - - -def resolve_sync_limit(args: argparse.Namespace) -> int: - if args.sync_limit is not None: - return args.sync_limit - - fixture_root = discover_fixture_root(args.config, args.account, args.workdir) - if fixture_root is None: - return 20 - - return count_eml_files(fixture_root) - - -def discover_fixture_root(config_path: str, account_name: str, workdir: str | None) -> Path | None: - config_file = resolve_path(config_path, workdir) - account = load_account_config(config_file, account_name) - if account is None: - return None - - if account.get("driver") != "dir": - return None - - value = account.get("path", "").strip() - if not value: - return None - - candidate = Path(value) - if not candidate.is_absolute(): - candidate = (config_file.parent / candidate).resolve() - if candidate.is_dir(): - return candidate - return None - - -def load_account_config(config_file: Path, account_name: str) -> dict[str, str] | None: - try: - raw = config_file.read_text(encoding="utf-8") - except OSError: - return None - - accounts: list[dict[str, str]] = [] - current: dict[str, str] | None = None - in_accounts = False - - for line in raw.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - - if stripped == "accounts:": - in_accounts = True - continue - if not in_accounts: - continue - - lstripped = line.lstrip() - indent = len(line) - len(lstripped) - if indent == 2 and lstripped.startswith("- "): - if current: - accounts.append(current) - current = {} - inline = lstripped[2:].strip() - if inline: - key, value = split_yaml_field(inline) - if key: - current[key] = value - continue - - if current is None: - continue - if indent < 4: - continue - - key, value = split_yaml_field(stripped) - if key: - current[key] = value - - if current: - accounts.append(current) - - for account in accounts: - if account.get("name") == account_name: - return account - return None - - -def split_yaml_field(line: str) -> tuple[str, str]: - if ":" not in line: - return "", "" - key, value = line.split(":", 1) - return key.strip(), value.strip() - - -def resolve_path(raw_path: str, workdir: str | None) -> Path: - path = Path(raw_path) - if path.is_absolute(): - return path - base = Path(workdir) if workdir else Path.cwd() - return (base / path).resolve() - - -def count_eml_files(root: Path) -> int: - return sum(1 for path in root.rglob("*.eml") if path.is_file()) - - -def run_mailcli_json(command: list[str], cwd: str | None) -> Any: - return run_json_command(command, cwd=cwd) - - -def run_python_json(command: list[str], cwd: str | None) -> Any: - return run_json_command(command, cwd=cwd) - - -def run_json_command(command: list[str], cwd: str | None) -> Any: - completed = subprocess.run( - command, - cwd=cwd, - capture_output=True, - text=True, - check=False, - ) - if completed.returncode != 0: - raise SystemExit(completed.stderr or completed.stdout) - try: - return json.loads(completed.stdout) - except json.JSONDecodeError as exc: - raise SystemExit(f"expected JSON output from {' '.join(command)}: {exc}") from exc - - -def write_json(path: Path, value: Any) -> None: - path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - -def write_text(path: Path, value: str) -> None: - path.write_text(value, encoding="utf-8") - - -def reset_index_file(path: str) -> None: - target = Path(path) - if target.exists(): - target.unlink() - # The SQLite backend remaps .json paths to .db; clean both so the demo - # always starts from an empty index regardless of which extension was used. - if target.suffix == ".json": - db_sibling = target.with_suffix(".db") - if db_sibling.exists(): - db_sibling.unlink() - - -def normalize_demo_json(value: Any) -> Any: - if isinstance(value, dict): - out: dict[str, Any] = {} - for key, item in value.items(): - if key == "indexed_at" and isinstance(item, str): - out[key] = CANONICAL_INDEXED_AT - continue - if key in {"index", "index_path"} and isinstance(item, str): - out[key] = CANONICAL_INDEX_PATH - continue - if key == "config" and isinstance(item, str): - out[key] = CANONICAL_CONFIG_PATH - continue - out[key] = normalize_demo_json(item) - return out - if isinstance(value, list): - return [normalize_demo_json(item) for item in value] - return value - - -def normalize_reply_mime(mime: str) -> str: - lines = [] - for line in mime.splitlines(): - if line.startswith("Message-ID: "): - lines.append(f"Message-ID: {CANONICAL_MESSAGE_ID}") - continue - if line.startswith("Date: "): - lines.append(f"Date: {CANONICAL_MIME_DATE}") - continue - lines.append(line) - return "\n".join(lines) - - -def compare_artifact_dirs(generated: Path, target: Path) -> list[str]: - expected_files = sorted(path.name for path in generated.iterdir() if path.is_file()) - mismatches: list[str] = [] - - for name in expected_files: - generated_path = generated / name - target_path = target / name - if not target_path.exists(): - mismatches.append(f"missing artifact: {target_path}") - continue - if not filecmp.cmp(generated_path, target_path, shallow=False): - mismatches.append(f"artifact drift: {target_path}") - - target_files = sorted(path.name for path in target.iterdir() if path.is_file()) - for name in target_files: - if name not in expected_files: - mismatches.append(f"unexpected artifact: {target / name}") - - return mismatches - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/python/reply_dry_run.py b/examples/python/reply_dry_run.py deleted file mode 100644 index 929bc06..0000000 --- a/examples/python/reply_dry_run.py +++ /dev/null @@ -1,22 +0,0 @@ -import subprocess -import sys - - -def main() -> int: - if len(sys.argv) != 2: - print("usage: python reply_dry_run.py ", file=sys.stderr) - return 1 - - result = subprocess.run( - ["mailcli", "reply", "--dry-run", sys.argv[1]], - capture_output=True, - text=True, - check=True, - ) - - print(result.stdout) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/internal/config/config.go b/internal/config/config.go index 3a6e254..df44f70 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,13 +50,20 @@ func Marshal(cfg Config) ([]byte, error) { return yaml.Marshal(cfg) } -func Unmarshal(data []byte) (Config, error) { +func UnmarshalRaw(data []byte) (Config, error) { var cfg Config err := yaml.Unmarshal(data, &cfg) if err != nil { return Config{}, err } + return cfg, nil +} +func Unmarshal(data []byte) (Config, error) { + cfg, err := UnmarshalRaw(data) + if err != nil { + return Config{}, err + } for i := range cfg.Accounts { cfg.Accounts[i].Password = expandSecretEnv(cfg.Accounts[i].Password) cfg.Accounts[i].SMTPPassword = expandSecretEnv(cfg.Accounts[i].SMTPPassword) @@ -77,10 +84,25 @@ func Load(path string) (Config, error) { } baseDir := filepath.Dir(path) - for i := range cfg.Accounts { - cfg.Accounts[i].Path = resolveConfigPath(baseDir, cfg.Accounts[i].Path) + resolveConfigPaths(baseDir, &cfg) + + return cfg, nil +} + +func LoadRaw(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err } + cfg, err := UnmarshalRaw(data) + if err != nil { + return Config{}, err + } + + baseDir := filepath.Dir(path) + resolveConfigPaths(baseDir, &cfg) + return cfg, nil } @@ -117,3 +139,9 @@ func resolveConfigPath(baseDir, value string) string { } return filepath.Clean(filepath.Join(baseDir, trimmed)) } + +func resolveConfigPaths(baseDir string, cfg *Config) { + for i := range cfg.Accounts { + cfg.Accounts[i].Path = resolveConfigPath(baseDir, cfg.Accounts[i].Path) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dc38222..9cf86b8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -156,6 +156,35 @@ accounts: } } +func TestUnmarshalRawPreservesSecretEnvironmentReferences(t *testing.T) { + t.Setenv("MAILCLI_IMAP_PASSWORD", "imap-secret") + t.Setenv("MAILCLI_SMTP_PASSWORD", "smtp-secret") + + cfg, err := UnmarshalRaw([]byte(` +current_account: work +accounts: + - name: work + driver: imap + password: ${MAILCLI_IMAP_PASSWORD} + smtp_password: ${MAILCLI_SMTP_PASSWORD} +`)) + if err != nil { + t.Fatalf("expected raw unmarshal to succeed: %v", err) + } + + account, err := cfg.ResolveAccount("work") + if err != nil { + t.Fatalf("expected account to resolve: %v", err) + } + + if account.Password != "${MAILCLI_IMAP_PASSWORD}" { + t.Fatalf("expected raw imap password reference, got %q", account.Password) + } + if account.SMTPPassword != "${MAILCLI_SMTP_PASSWORD}" { + t.Fatalf("expected raw smtp password reference, got %q", account.SMTPPassword) + } +} + func TestUnmarshalDoesNotExpandEnvironmentVariablesForNonSecretFields(t *testing.T) { t.Setenv("MAILCLI_ACCOUNT_NAME", "expanded-name") diff --git a/pkg/schema/capabilities.go b/pkg/schema/capabilities.go new file mode 100644 index 0000000..9c632e2 --- /dev/null +++ b/pkg/schema/capabilities.go @@ -0,0 +1,29 @@ +package schema + +type AccountCapabilities struct { + Account string `json:"account" yaml:"account"` + Driver string `json:"driver" yaml:"driver"` + Mailbox string `json:"mailbox,omitempty" yaml:"mailbox,omitempty"` + Capabilities MailCapabilities `json:"capabilities" yaml:"capabilities"` + Configuration AccountCapabilityConfiguration `json:"configuration" yaml:"configuration"` +} + +type MailCapabilities struct { + List bool `json:"list" yaml:"list"` + FetchRaw bool `json:"fetch_raw" yaml:"fetch_raw"` + Search bool `json:"search" yaml:"search"` + Threads bool `json:"threads" yaml:"threads"` + Watch bool `json:"watch" yaml:"watch"` + Send bool `json:"send" yaml:"send"` + Reply bool `json:"reply" yaml:"reply"` + Delete bool `json:"delete" yaml:"delete"` + Move bool `json:"move" yaml:"move"` + MarkRead bool `json:"mark_read" yaml:"mark_read"` + LocalIndex bool `json:"local_index" yaml:"local_index"` +} + +type AccountCapabilityConfiguration struct { + InboundConfigured bool `json:"inbound_configured" yaml:"inbound_configured"` + OutboundConfigured bool `json:"outbound_configured" yaml:"outbound_configured"` + UsesLocalStorage bool `json:"uses_local_storage" yaml:"uses_local_storage"` +} diff --git a/pkg/schema/config_diagnostics.go b/pkg/schema/config_diagnostics.go new file mode 100644 index 0000000..bd1ea43 --- /dev/null +++ b/pkg/schema/config_diagnostics.go @@ -0,0 +1,30 @@ +package schema + +type ConfigInitResult struct { + Status string `json:"status"` + ConfigPath string `json:"config_path"` + Account string `json:"account"` + Driver string `json:"driver"` +} + +type ConfigDiagnostics struct { + ConfigPath string `json:"config_path"` + Status string `json:"status"` + Accounts []AccountDiagnostic `json:"accounts"` + Problems []ConfigDiagnostic `json:"problems,omitempty"` +} + +type AccountDiagnostic struct { + Name string `json:"name"` + Driver string `json:"driver"` + Status string `json:"status"` + Capabilities AccountCapabilities `json:"capabilities"` + Checks []ConfigDiagnostic `json:"checks"` +} + +type ConfigDiagnostic struct { + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` +} diff --git a/tools/README.md b/tools/README.md index 39c1afc..6c1abac 100644 --- a/tools/README.md +++ b/tools/README.md @@ -11,70 +11,42 @@ function-calling / tool-use support can invoke via subprocess. tools/ openai.json OpenAI function-calling schema (tools[] array) anthropic.json Anthropic tool-use schema (tools[] with input_schema) - agent_example.py Minimal Python watch → LLM → reply pipeline README.md This file ``` --- -## Quick start: on-demand tool use +## Quick start: on-demand tool use from Go -### Python (Anthropic) +Load a schema JSON file, pass it to your model runtime, then map tool calls back +to `mailcli` subprocess commands. -```python -import json, subprocess, anthropic +```go +package main -def run_mailcli(*args) -> str: - r = subprocess.run(["mailcli", *args], capture_output=True, text=True) - return r.stdout - -with open("tools/anthropic.json") as f: - tools = json.load(f)["tools"] - -client = anthropic.Anthropic() - -# Pass tools to the model -response = client.messages.create( - model="claude-3-5-sonnet-20241022", - max_tokens=1024, - tools=tools, - messages=[{"role": "user", "content": "Search my inbox for invoices from last week"}], +import ( + "encoding/json" + "fmt" + "os/exec" ) -# Execute tool calls -for block in response.content: - if block.type == "tool_use": - name = block.name # e.g. "mailcli_search" - inp = block.input # dict of parameters - - # Map tool names to mailcli subcommands + flags - cmd = tool_to_cmd(name, inp) - result = run_mailcli(*cmd) - print(result) -``` - -### Python (OpenAI) - -```python -import json, subprocess -from openai import OpenAI - -with open("tools/openai.json") as f: - tools = json.load(f) - -client = OpenAI() -response = client.chat.completions.create( - model="gpt-4o", - tools=tools, - messages=[{"role": "user", "content": "Search my inbox for invoices from last week"}], -) +func runMailCLI(args ...string) (string, error) { + cmd := exec.Command("mailcli", args...) + out, err := cmd.CombinedOutput() + return string(out), err +} -for choice in response.choices: - for call in (choice.message.tool_calls or []): - args = json.loads(call.function.arguments) - cmd = tool_to_cmd(call.function.name, args) - result = subprocess.run(["mailcli", *cmd], capture_output=True, text=True) - print(result.stdout) +func main() { + cmdArgs := toolToCommand("mailcli_search", map[string]any{ + "query": "invoice", + "limit": 10, + }) + out, err := runMailCLI(cmdArgs...) + if err != nil { + panic(err) + } + fmt.Print(out) +} ``` --- @@ -97,65 +69,90 @@ for choice in response.choices: | `dest_mailbox` | positional arg `[1]`| | `draft` | JSON string arg | -### Example mapping function (Python) - -```python -import json - -def tool_to_cmd(name: str, inp: dict) -> list[str]: - sub = name.replace("mailcli_", "") # mailcli_search → search - cmd = [sub] - - draft = inp.pop("draft", None) - query = inp.pop("query", None) - dest = inp.pop("dest_mailbox", None) - full = inp.pop("full", False) - refresh = inp.pop("refresh", False) - unread = inp.pop("unread", False) - has_codes = inp.pop("has_codes", False) +### Example mapping function (Go) - if query: - cmd.append(query) - if draft: - cmd.append(json.dumps(draft)) - if dest: - cmd.append(dest) +```go +package main - for k, v in inp.items(): - flag = "--" + k.replace("_", "-") - cmd += [flag, str(v)] - - if full: cmd.append("--full") - if refresh: cmd.append("--refresh") - if unread: cmd.append("--unread") - if has_codes: cmd.append("--has-codes") +import ( + "encoding/json" + "fmt" + "strings" +) - return cmd +func toolToCommand(name string, input map[string]any) []string { + sub := strings.TrimPrefix(name, "mailcli_") + cmd := []string{sub} + + query, _ := input["query"].(string) + dest, _ := input["dest_mailbox"].(string) + draft := input["draft"] + full, _ := input["full"].(bool) + refresh, _ := input["refresh"].(bool) + unread, _ := input["unread"].(bool) + hasCodes, _ := input["has_codes"].(bool) + + delete(input, "query") + delete(input, "dest_mailbox") + delete(input, "draft") + delete(input, "full") + delete(input, "refresh") + delete(input, "unread") + delete(input, "has_codes") + + if query != "" { + cmd = append(cmd, query) + } + if draft != nil { + raw, _ := json.Marshal(draft) + cmd = append(cmd, string(raw)) + } + if dest != "" { + cmd = append(cmd, dest) + } + + for key, value := range input { + flag := "--" + strings.ReplaceAll(key, "_", "-") + cmd = append(cmd, flag, fmt.Sprint(value)) + } + if full { + cmd = append(cmd, "--full") + } + if refresh { + cmd = append(cmd, "--refresh") + } + if unread { + cmd = append(cmd, "--unread") + } + if hasCodes { + cmd = append(cmd, "--has-codes") + } + return cmd +} ``` --- ## Passive monitoring: `mailcli watch` pipeline -For continuous inbox monitoring with automatic AI replies: +For continuous inbox monitoring with agent-side draft generation: ```bash -# Install deps -pip install anthropic - -# Set API key -export ANTHROPIC_API_KEY=sk-ant-... +# Configure the sending identity used for reply dry-runs or sends export MAILCLI_ACCOUNT=work +export MAILCLI_FROM_ADDRESS=support@example.com # Human-in-the-loop (prints draft JSONs to stdout for review) -mailcli watch --account work | python3 tools/agent_example.py +mailcli watch --account work \ + | go run ./examples/go/watch_reply_agent --draft-replies # Fully automatic (use with caution!) -MAILCLI_AUTO_SEND=1 mailcli watch --account work | python3 tools/agent_example.py +mailcli watch --account work \ + | MAILCLI_AUTO_SEND=1 go run ./examples/go/watch_reply_agent --draft-replies # Watch multiple mailboxes mailcli watch --account work --mailbox INBOX --mailbox "Customer Support" \ - | python3 tools/agent_example.py + | go run ./examples/go/watch_reply_agent --draft-replies ``` ### Watch event schema (JSONL) diff --git a/tools/agent_example.py b/tools/agent_example.py deleted file mode 100644 index 19bdaf2..0000000 --- a/tools/agent_example.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -""" -mailcli AI Reply Agent — minimal example of a watch → LLM → reply pipeline. - -Usage: - mailcli watch --account work | python3 tools/agent_example.py - -Environment variables: - ANTHROPIC_API_KEY or OPENAI_API_KEY - MAILCLI_AUTO_SEND set to "1" to send replies automatically (default: draft only) - MAILCLI_ACCOUNT account name passed to mailcli reply - MAILCLI_DRY_RUN set to "1" to print the MIME instead of sending - -Requirements: - pip install anthropic # or openai -""" - -import json -import os -import subprocess -import sys -from datetime import datetime, timezone - -# ── Configuration ───────────────────────────────────────────────────────────── - -ACCOUNT = os.environ.get("MAILCLI_ACCOUNT", "") -AUTO_SEND = os.environ.get("MAILCLI_AUTO_SEND", "0") == "1" -DRY_RUN = os.environ.get("MAILCLI_DRY_RUN", "0") == "1" - -SYSTEM_PROMPT = """You are a helpful email assistant. When given an incoming email, -draft a concise, professional reply. Output ONLY a JSON object with these fields: -{ - "should_reply": true/false, - "reply_body_md": "Your reply in Markdown", - "reasoning": "one-sentence reason" -} -If the email is a newsletter, automated notification, or doesn't warrant a reply, -set should_reply to false.""" - - -# ── LLM call (Anthropic by default, falls back to OpenAI) ──────────────────── - -def ask_llm(email_json: dict) -> dict: - prompt = f"Incoming email:\n```json\n{json.dumps(email_json, ensure_ascii=False, indent=2)}\n```" - - if os.environ.get("ANTHROPIC_API_KEY"): - import anthropic - client = anthropic.Anthropic() - msg = client.messages.create( - model="claude-3-5-sonnet-20241022", - max_tokens=1024, - system=SYSTEM_PROMPT, - messages=[{"role": "user", "content": prompt}], - ) - raw = msg.content[0].text - elif os.environ.get("OPENAI_API_KEY"): - from openai import OpenAI - client = OpenAI() - resp = client.chat.completions.create( - model="gpt-4o-mini", - messages=[ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": prompt}, - ], - response_format={"type": "json_object"}, - ) - raw = resp.choices[0].message.content - else: - print("[agent] No LLM API key found — skipping", file=sys.stderr) - return {"should_reply": False} - - try: - return json.loads(raw) - except json.JSONDecodeError: - print(f"[agent] LLM returned non-JSON: {raw}", file=sys.stderr) - return {"should_reply": False} - - -# ── mailcli helper ──────────────────────────────────────────────────────────── - -def mailcli_reply(original_msg: dict, reply_body_md: str) -> None: - meta = original_msg.get("meta", {}) - frm = meta.get("from") or {} - - # Build ReplyDraft JSON matching mailcli's schema. - reply_draft = { - "reply_to_message_id": original_msg.get("id", ""), - "references": original_msg.get("references", [original_msg.get("id", "")]), - "to": [{"name": frm.get("name", ""), "address": frm.get("address", "")}], - "subject": meta.get("subject", ""), - "body_md": reply_body_md, - } - - cmd = ["mailcli", "reply", json.dumps(reply_draft)] - if ACCOUNT: - cmd += ["--account", ACCOUNT] - if DRY_RUN: - cmd += ["--dry-run"] - - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"[agent] reply failed: {result.stderr}", file=sys.stderr) - else: - print(f"[agent] {'sent' if not DRY_RUN else 'dry-run'}: {json.loads(result.stdout)}", file=sys.stderr) - - -# ── Main event loop ─────────────────────────────────────────────────────────── - -def main(): - print("[agent] starting — reading from stdin (mailcli watch output)", file=sys.stderr) - - for line in sys.stdin: - line = line.strip() - if not line: - continue - - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - - etype = event.get("event") - - if etype == "watching": - print(f"[agent] monitoring {event.get('account')}/{event.get('mailbox')}", file=sys.stderr) - - elif etype == "new_message": - msg = event.get("message") or {} - subject = (msg.get("meta") or {}).get("subject", "(no subject)") - print(f"[agent] new message: {subject}", file=sys.stderr) - - decision = ask_llm(msg) - - if not decision.get("should_reply"): - print(f"[agent] skip ({decision.get('reasoning', 'no reason')})", file=sys.stderr) - continue - - reply_body = decision.get("reply_body_md", "") - print(f"[agent] replying — {decision.get('reasoning', '')}", file=sys.stderr) - - if AUTO_SEND: - mailcli_reply(msg, reply_body) - else: - # Human-in-the-loop: print draft to stdout for review. - draft = { - "ts": datetime.now(timezone.utc).isoformat(), - "in_reply_to": msg.get("id"), - "subject": (msg.get("meta") or {}).get("subject"), - "draft_body_md": reply_body, - "reasoning": decision.get("reasoning"), - } - print(json.dumps(draft, ensure_ascii=False)) - - elif etype == "error": - print(f"[agent] error: {event.get('error')}", file=sys.stderr) - - elif etype == "heartbeat": - pass # ignore - - -if __name__ == "__main__": - main()