nmail-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nmail_cli-0.1.0/.gitignore +32 -0
- nmail_cli-0.1.0/PKG-INFO +53 -0
- nmail_cli-0.1.0/README.md +43 -0
- nmail_cli-0.1.0/nmail_cli/__init__.py +3 -0
- nmail_cli-0.1.0/nmail_cli/__main__.py +4 -0
- nmail_cli-0.1.0/nmail_cli/cli.py +774 -0
- nmail_cli-0.1.0/pyproject.toml +19 -0
- nmail_cli-0.1.0/tests/conftest.py +13 -0
- nmail_cli-0.1.0/tests/test_cli.py +272 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
build-test/
|
|
9
|
+
|
|
10
|
+
# 打包中间产物(由 scripts/sync_frontend.sh 生成,不入库)
|
|
11
|
+
backend/app/static/
|
|
12
|
+
|
|
13
|
+
# 前端
|
|
14
|
+
frontend/node_modules/
|
|
15
|
+
frontend/dist/
|
|
16
|
+
|
|
17
|
+
# 运行与工具
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.DS_Store
|
|
20
|
+
Thumbs.db
|
|
21
|
+
|
|
22
|
+
# 个人数据与敏感文件(严禁入库/推送)
|
|
23
|
+
personal-data/
|
|
24
|
+
secrets.json
|
|
25
|
+
*.db
|
|
26
|
+
*.db-wal
|
|
27
|
+
*.db-shm
|
|
28
|
+
# 含第三方 OAuth 凭据值的方案文档(审核结论:凭据值永不入库)
|
|
29
|
+
docs/内置公开OAuth凭证一键授权方案.md
|
|
30
|
+
|
|
31
|
+
# 参考代码
|
|
32
|
+
reference/
|
nmail_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nmail-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nmail 对外 API 命令行客户端——为 agent/skill 设计(JSON envelope + exit code 契约 + 两阶段确认)
|
|
5
|
+
Author: Nmail
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: requests>=2.31
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# nmail-cli
|
|
12
|
+
|
|
13
|
+
Nmail 对外 API 的命令行客户端(AGENT_SKILL_PLAN P2 / REDESIGN_PLAN §19)——为外部
|
|
14
|
+
agent 与 skill 设计:JSON envelope(stdout)、exit code 契约、发送类两阶段确认。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx nmail-cli@latest --help # 零安装(PyPI 发布后)
|
|
20
|
+
# 或
|
|
21
|
+
pipx install nmail-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
发布随主仓发版流程进行(`nmail-cli` 为独立 PyPI 包名)。
|
|
25
|
+
|
|
26
|
+
## 快速上手
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
nmail-cli auth login # 本机 Nmail(127.0.0.1:8720)自动配对建 Key(默认 read scope)
|
|
30
|
+
nmail-cli auth login --scopes read,write,send # 需要发送时显式扩权
|
|
31
|
+
nmail-cli +me # 账号列表与健康
|
|
32
|
+
nmail-cli emails list --limit 5
|
|
33
|
+
nmail-cli emails read 254 --save-attachments ./downloads
|
|
34
|
+
nmail-cli drafts reply --email-id 254 --body-file ./reply.md
|
|
35
|
+
nmail-cli drafts send 44 # 第一阶段:打印摘要,exit 8
|
|
36
|
+
nmail-cli drafts send 44 --confirmed # 第二阶段:用户许可后重放
|
|
37
|
+
nmail-cli watch # NDJSON 每行一封新邮件
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
远程(自建隧道):`nmail-cli auth login --base-url https://nmail.example.com --key nmail_xxx`
|
|
41
|
+
(`/api/extkeys` 管理面仅本机可达,远程需从 Nmail 设置-API 复制 Key 粘贴)。
|
|
42
|
+
|
|
43
|
+
## 契约
|
|
44
|
+
|
|
45
|
+
- **stdout** 只输出 JSON envelope:成功 `{"ok":true,"data":…}`,失败
|
|
46
|
+
`{"ok":false,"error":{"code","message",…}}`;人话日志走 stderr。
|
|
47
|
+
- **exit code**:`0` 成功 · `1` 上游 5xx 可重试×2 · `2` 参数不合规不重试 · `3` 认证失效/未启用
|
|
48
|
+
走 `auth login` · `4` 连不上(检查 Nmail 是否运行/隧道) · `6` 业务永久拒绝不重试 ·
|
|
49
|
+
`7` 限流按 `Retry-After` 等待 · `8` 需两阶段确认(停下等用户,不得同轮自确认)。
|
|
50
|
+
- 配置:`~/.config/nmail-cli/config.json`(0600);环境变量 `NMAIL_BASE_URL` / `NMAIL_API_KEY`
|
|
51
|
+
可覆盖(CI/一次性场景)。
|
|
52
|
+
|
|
53
|
+
服务端 API 与安全模型见 `docs/对外API使用指南.md`;面向 agent 的技能说明见 `skills/SKILL.md`(P3)。
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# nmail-cli
|
|
2
|
+
|
|
3
|
+
Nmail 对外 API 的命令行客户端(AGENT_SKILL_PLAN P2 / REDESIGN_PLAN §19)——为外部
|
|
4
|
+
agent 与 skill 设计:JSON envelope(stdout)、exit code 契约、发送类两阶段确认。
|
|
5
|
+
|
|
6
|
+
## 安装
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
uvx nmail-cli@latest --help # 零安装(PyPI 发布后)
|
|
10
|
+
# 或
|
|
11
|
+
pipx install nmail-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
发布随主仓发版流程进行(`nmail-cli` 为独立 PyPI 包名)。
|
|
15
|
+
|
|
16
|
+
## 快速上手
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
nmail-cli auth login # 本机 Nmail(127.0.0.1:8720)自动配对建 Key(默认 read scope)
|
|
20
|
+
nmail-cli auth login --scopes read,write,send # 需要发送时显式扩权
|
|
21
|
+
nmail-cli +me # 账号列表与健康
|
|
22
|
+
nmail-cli emails list --limit 5
|
|
23
|
+
nmail-cli emails read 254 --save-attachments ./downloads
|
|
24
|
+
nmail-cli drafts reply --email-id 254 --body-file ./reply.md
|
|
25
|
+
nmail-cli drafts send 44 # 第一阶段:打印摘要,exit 8
|
|
26
|
+
nmail-cli drafts send 44 --confirmed # 第二阶段:用户许可后重放
|
|
27
|
+
nmail-cli watch # NDJSON 每行一封新邮件
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
远程(自建隧道):`nmail-cli auth login --base-url https://nmail.example.com --key nmail_xxx`
|
|
31
|
+
(`/api/extkeys` 管理面仅本机可达,远程需从 Nmail 设置-API 复制 Key 粘贴)。
|
|
32
|
+
|
|
33
|
+
## 契约
|
|
34
|
+
|
|
35
|
+
- **stdout** 只输出 JSON envelope:成功 `{"ok":true,"data":…}`,失败
|
|
36
|
+
`{"ok":false,"error":{"code","message",…}}`;人话日志走 stderr。
|
|
37
|
+
- **exit code**:`0` 成功 · `1` 上游 5xx 可重试×2 · `2` 参数不合规不重试 · `3` 认证失效/未启用
|
|
38
|
+
走 `auth login` · `4` 连不上(检查 Nmail 是否运行/隧道) · `6` 业务永久拒绝不重试 ·
|
|
39
|
+
`7` 限流按 `Retry-After` 等待 · `8` 需两阶段确认(停下等用户,不得同轮自确认)。
|
|
40
|
+
- 配置:`~/.config/nmail-cli/config.json`(0600);环境变量 `NMAIL_BASE_URL` / `NMAIL_API_KEY`
|
|
41
|
+
可覆盖(CI/一次性场景)。
|
|
42
|
+
|
|
43
|
+
服务端 API 与安全模型见 `docs/对外API使用指南.md`;面向 agent 的技能说明见 `skills/SKILL.md`(P3)。
|
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""nmail-cli 主实现:Nmail 对外 API 薄客户端(AGENT_SKILL_PLAN P2,REDESIGN_PLAN §19)。
|
|
2
|
+
|
|
3
|
+
契约(skills/SKILL.md 与 README 同源):
|
|
4
|
+
- stdout 只输出 JSON envelope:成功 ``{"ok":true,"data":…}``,失败
|
|
5
|
+
``{"ok":false,"error":{"code","message",…}}``;人话日志一律走 stderr。
|
|
6
|
+
- exit code:0 成功 · 1 上游 5xx(可重试×2)· 2 参数不合规(不重试)· 3 认证失效/未启用
|
|
7
|
+
(走 auth login)· 4 连不上(检查 Nmail/隧道,可重试×2)· 6 业务永久拒绝(不重试)·
|
|
8
|
+
7 限流(按 Retry-After 等待)· 8 需两阶段确认(停下等用户许可,不得同轮自确认)。
|
|
9
|
+
- 发送类两阶段确认:``drafts send`` 不带 --confirmed 时打印 summary 并 exit 8;
|
|
10
|
+
用户许可后**原参数 + --confirmed** 重放(服务端建草稿→approve 天然两段,CLI 层确认即可)。
|
|
11
|
+
- 配置:~/.config/nmail-cli/config.json(0600);环境变量 NMAIL_BASE_URL/NMAIL_API_KEY 优先。
|
|
12
|
+
- 本机(Host 为 127.0.0.1/localhost/::1)auth login 经内部 /api/extkeys 自动配对建 Key
|
|
13
|
+
(与设置页同信任级);远程必须 --key 粘贴(管理面不出本机)。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import socket
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
EXIT_OK = 0
|
|
27
|
+
EXIT_UPSTREAM = 1
|
|
28
|
+
EXIT_BAD_PARAMS = 2
|
|
29
|
+
EXIT_AUTH = 3
|
|
30
|
+
EXIT_NETWORK = 4
|
|
31
|
+
EXIT_PERMANENT = 6
|
|
32
|
+
EXIT_RATE_LIMIT = 7
|
|
33
|
+
EXIT_CONFIRM = 8
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8720"
|
|
36
|
+
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
37
|
+
|
|
38
|
+
_CODE_EXIT = {
|
|
39
|
+
"bad_request": EXIT_BAD_PARAMS,
|
|
40
|
+
"invalid_params": EXIT_BAD_PARAMS,
|
|
41
|
+
"invalid_key": EXIT_AUTH,
|
|
42
|
+
"forbidden": EXIT_AUTH,
|
|
43
|
+
"not_found": EXIT_PERMANENT,
|
|
44
|
+
"rate_limited": EXIT_RATE_LIMIT,
|
|
45
|
+
"upstream": EXIT_UPSTREAM,
|
|
46
|
+
"server_error": EXIT_UPSTREAM,
|
|
47
|
+
}
|
|
48
|
+
_STATUS_EXIT = {401: EXIT_AUTH, 403: EXIT_AUTH, 404: EXIT_PERMANENT, 429: EXIT_RATE_LIMIT}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CliError(Exception):
|
|
52
|
+
"""业务失败:message 照 error.message 原文透出,code/exit_code 决定 agent 下一步。"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str, code: str = "server_error",
|
|
55
|
+
exit_code: int = EXIT_UPSTREAM, extra: dict | None = None):
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.message = message
|
|
58
|
+
self.code = code
|
|
59
|
+
self.exit_code = exit_code
|
|
60
|
+
self.extra = extra or {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _stderr(msg: str) -> None:
|
|
64
|
+
print(msg, file=sys.stderr, flush=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_PENDING_NOTICE: dict = {} # 更新检查提示(_probe_update_notice 填充,_emit 消费)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit(data) -> None: # noqa: ANN001 — envelope data 为任意 JSON 值
|
|
71
|
+
envelope: dict = {"ok": True, "data": data}
|
|
72
|
+
if _PENDING_NOTICE:
|
|
73
|
+
envelope["_notice"] = _PENDING_NOTICE # 更新检查(SKILL.md「更新检查」节消费)
|
|
74
|
+
print(json.dumps(envelope, ensure_ascii=False), flush=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _semver_tuple(v: str) -> tuple[int, ...]:
|
|
78
|
+
parts: list[int] = []
|
|
79
|
+
for seg in v.strip().lstrip("v").split("."):
|
|
80
|
+
digits = ""
|
|
81
|
+
for ch in seg:
|
|
82
|
+
if ch.isdigit():
|
|
83
|
+
digits += ch
|
|
84
|
+
else:
|
|
85
|
+
break
|
|
86
|
+
parts.append(int(digits or 0))
|
|
87
|
+
return tuple(parts[:3]) if parts else (0, 0, 0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _probe_update_notice() -> None:
|
|
91
|
+
"""B2 版本协商:服务端 /health 带 version,CLI 落后时在输出附 _notice.update。
|
|
92
|
+
尽力而为——任何失败都静默跳过(旧服务端无 version、离线、隧道断均不扰主流程)。"""
|
|
93
|
+
import nmail_cli
|
|
94
|
+
|
|
95
|
+
base_url = None
|
|
96
|
+
try:
|
|
97
|
+
base_url = os.environ.get("NMAIL_BASE_URL") or _load_config().get("base_url")
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
return
|
|
100
|
+
if not base_url:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
client = Client(str(base_url))
|
|
104
|
+
status, body, _ = client._call("GET", "/api/ext/v1/health", None, None, None, None)
|
|
105
|
+
server = str((body or {}).get("version") or "").strip()
|
|
106
|
+
if status < 400 and server and _semver_tuple(server) > _semver_tuple(nmail_cli.__version__):
|
|
107
|
+
globals()["_PENDING_NOTICE"] = {
|
|
108
|
+
"update": {"cli": nmail_cli.__version__, "server": server,
|
|
109
|
+
"hint": "CLI 落后于服务端,建议升级后重新安装 skill",
|
|
110
|
+
"upgrade": "uvx nmail-cli@latest",
|
|
111
|
+
"skill": "npx skills add nathanpenny520/Nmail -g -y"},
|
|
112
|
+
}
|
|
113
|
+
except Exception: # noqa: BLE001 — 更新检查失败不影响命令本身
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── 配置 ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _config_path() -> Path:
|
|
120
|
+
return Path(os.environ.get("NMAIL_CLI_CONFIG_DIR")
|
|
121
|
+
or Path.home() / ".config" / "nmail-cli") / "config.json"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_config() -> dict:
|
|
125
|
+
try:
|
|
126
|
+
return json.loads(_config_path().read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_config(base_url: str, key: str) -> Path:
|
|
132
|
+
path = _config_path()
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
path.parent.chmod(0o700)
|
|
135
|
+
path.write_text(json.dumps({"base_url": base_url, "key": key}, ensure_ascii=False),
|
|
136
|
+
encoding="utf-8")
|
|
137
|
+
path.chmod(0o600)
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve_base_url(arg_value: str | None) -> str:
|
|
142
|
+
url = arg_value or os.environ.get("NMAIL_BASE_URL") or _load_config().get("base_url")
|
|
143
|
+
if not url:
|
|
144
|
+
raise CliError("尚未配置:先运行 nmail-cli auth login(或设 NMAIL_BASE_URL)",
|
|
145
|
+
code="invalid_key", exit_code=EXIT_AUTH)
|
|
146
|
+
return url.rstrip("/")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_key(arg_value: str | None) -> str:
|
|
150
|
+
key = arg_value or os.environ.get("NMAIL_API_KEY") or _load_config().get("key")
|
|
151
|
+
if not key:
|
|
152
|
+
raise CliError("尚未配置 API Key:先运行 nmail-cli auth login(或设 NMAIL_API_KEY)",
|
|
153
|
+
code="invalid_key", exit_code=EXIT_AUTH)
|
|
154
|
+
return key
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── HTTP 客户端 ─────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
DEFAULT_TRANSPORT = None # 测试注入点:ASGI TestClient 等替代 requests 传输
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Client:
|
|
163
|
+
"""envelope 语义的 API 客户端;transport 可注入(测试用 ASGI TestClient)。
|
|
164
|
+
|
|
165
|
+
transport(method, path, headers, json_body, files, params) -> (status, body, content)
|
|
166
|
+
body 为解析后的 JSON(非 JSON 响应为 {}),content 为原始字节(附件下载用)。
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, base_url: str, key: str | None = None, transport=None):
|
|
170
|
+
self.base_url = base_url.rstrip("/")
|
|
171
|
+
self.key = key or ""
|
|
172
|
+
self._transport = transport or DEFAULT_TRANSPORT or self._requests_transport
|
|
173
|
+
|
|
174
|
+
def _requests_transport(self, method: str, path: str, headers: dict,
|
|
175
|
+
json_body=None, files=None, params=None) -> tuple[int, dict, bytes]:
|
|
176
|
+
import requests # 惰性导入:ASGI 传输(测试)不需要;正常安装已随包带上
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
resp = requests.request(method, self.base_url + path, headers=headers,
|
|
180
|
+
json=json_body, files=files, params=params, timeout=60)
|
|
181
|
+
except requests.exceptions.RequestException as exc:
|
|
182
|
+
raise CliError(f"连不上 Nmail({self.base_url}):{exc.__class__.__name__}——"
|
|
183
|
+
"检查 Nmail 是否在运行、隧道是否在位",
|
|
184
|
+
code="network", exit_code=EXIT_NETWORK) from exc
|
|
185
|
+
try:
|
|
186
|
+
body = resp.json()
|
|
187
|
+
except ValueError:
|
|
188
|
+
body = {}
|
|
189
|
+
return resp.status_code, body, resp.content
|
|
190
|
+
|
|
191
|
+
def request(self, method: str, path: str, *, json_body=None, files=None,
|
|
192
|
+
params=None, key: str | None = None):
|
|
193
|
+
status, body, _ = self._call(method, path, json_body, files, params, key)
|
|
194
|
+
if status >= 400:
|
|
195
|
+
raise self._error(status, body)
|
|
196
|
+
return body
|
|
197
|
+
|
|
198
|
+
def download(self, path: str) -> bytes:
|
|
199
|
+
status, body, content = self._call("GET", path, None, None, None, None)
|
|
200
|
+
if status >= 400:
|
|
201
|
+
raise self._error(status, body)
|
|
202
|
+
return content
|
|
203
|
+
|
|
204
|
+
def _call(self, method: str, path: str, json_body, files, params,
|
|
205
|
+
key: str | None) -> tuple[int, dict, bytes]:
|
|
206
|
+
headers = {"X-Api-Key": key if key is not None else self.key}
|
|
207
|
+
return self._transport(method, path, headers, json_body, files, params)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _error(status: int, body) -> CliError:
|
|
211
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
212
|
+
if err:
|
|
213
|
+
code = err.get("code") or "server_error"
|
|
214
|
+
exit_code = _CODE_EXIT.get(code) or _STATUS_EXIT.get(status) or EXIT_UPSTREAM
|
|
215
|
+
extra = {k: v for k, v in err.items() if k not in ("code", "message")}
|
|
216
|
+
return CliError(err.get("message") or f"HTTP {status}",
|
|
217
|
+
code=code, exit_code=exit_code, extra=extra)
|
|
218
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
219
|
+
return CliError(str(detail) or f"HTTP {status}",
|
|
220
|
+
code="server_error", exit_code=EXIT_UPSTREAM)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _fail(exc: CliError) -> int:
|
|
224
|
+
payload = {"ok": False, "error": {"code": exc.code, "message": exc.message, **exc.extra}}
|
|
225
|
+
print(json.dumps(payload, ensure_ascii=False), flush=True)
|
|
226
|
+
return exc.exit_code
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main(argv: list[str] | None = None) -> int:
|
|
230
|
+
try:
|
|
231
|
+
args = build_parser().parse_args(argv)
|
|
232
|
+
if getattr(args, "command", None) != "auth":
|
|
233
|
+
_probe_update_notice() # B2:尽力而为,失败静默
|
|
234
|
+
return args.func(args)
|
|
235
|
+
except CliError as exc:
|
|
236
|
+
return _fail(exc)
|
|
237
|
+
except KeyboardInterrupt:
|
|
238
|
+
_stderr("\n已停止")
|
|
239
|
+
return EXIT_OK
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ── auth ────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
def _is_local(base_url: str) -> bool:
|
|
245
|
+
m = re.match(r"https?://([^/:?#]+)", base_url)
|
|
246
|
+
return bool(m) and m.group(1).strip("[]").lower() in _LOCAL_HOSTS
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _confirm(prompt: str, assumed_yes: bool) -> bool:
|
|
250
|
+
if assumed_yes:
|
|
251
|
+
return True
|
|
252
|
+
try:
|
|
253
|
+
return input(prompt).strip().lower() in ("", "y", "yes")
|
|
254
|
+
except EOFError:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_auth_login(args) -> int:
|
|
259
|
+
base_url = (args.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
260
|
+
client = Client(base_url)
|
|
261
|
+
client.request("GET", "/api/ext/v1/health") # 连通性自测(失败→network exit 4)
|
|
262
|
+
if args.key:
|
|
263
|
+
key = args.key
|
|
264
|
+
_stderr(f"已配置 Key({base_url})")
|
|
265
|
+
elif _is_local(base_url):
|
|
266
|
+
name = f"cli-{socket.gethostname()}"
|
|
267
|
+
key = client.request("POST", "/api/extkeys", json_body={
|
|
268
|
+
"name": name, "scopes": [s.strip() for s in args.scopes.split(",") if s.strip()],
|
|
269
|
+
})["key"]["key"]
|
|
270
|
+
_stderr(f"已在本机 Nmail 创建密钥「{name}」(scopes={args.scopes})")
|
|
271
|
+
try:
|
|
272
|
+
client.request("GET", "/api/ext/v1/accounts", key=key)
|
|
273
|
+
except CliError as exc:
|
|
274
|
+
if exc.code == "forbidden" and "未启用" in exc.message:
|
|
275
|
+
if _confirm("对外 API 未启用,现在打开?(Y/n)", args.yes):
|
|
276
|
+
client.request("POST", "/api/extkeys/enabled", json_body={"enabled": True})
|
|
277
|
+
_stderr("已启用对外 API")
|
|
278
|
+
else:
|
|
279
|
+
raise
|
|
280
|
+
else:
|
|
281
|
+
key = input(f"远程模式({base_url}):请在 Nmail 设置-API 复制密钥后粘贴: ").strip()
|
|
282
|
+
if not key:
|
|
283
|
+
raise CliError("未输入 Key", code="invalid_key", exit_code=EXIT_AUTH)
|
|
284
|
+
save_config(base_url, key)
|
|
285
|
+
_stderr(f"配置已保存:{_config_path()}")
|
|
286
|
+
_emit({"base_url": base_url, "key": key}) # 所见即所存(本机终端)
|
|
287
|
+
return EXIT_OK
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_auth_status(_args) -> int:
|
|
291
|
+
cfg = _load_config()
|
|
292
|
+
if not cfg.get("base_url") or not cfg.get("key"):
|
|
293
|
+
_emit({"logged_in": False})
|
|
294
|
+
return EXIT_OK
|
|
295
|
+
client = Client(cfg["base_url"], cfg["key"])
|
|
296
|
+
try:
|
|
297
|
+
accounts = client.request("GET", "/api/ext/v1/accounts")
|
|
298
|
+
_emit({"logged_in": True, "base_url": cfg["base_url"], "accounts": accounts["accounts"]})
|
|
299
|
+
except CliError as exc:
|
|
300
|
+
_emit({"logged_in": True, "base_url": cfg["base_url"],
|
|
301
|
+
"reachable": False, "error": {"code": exc.code, "message": exc.message}})
|
|
302
|
+
return EXIT_OK
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_auth_logout(_args) -> int:
|
|
306
|
+
path = _config_path()
|
|
307
|
+
removed = path.exists()
|
|
308
|
+
if removed:
|
|
309
|
+
path.unlink()
|
|
310
|
+
_emit({"cleared": removed})
|
|
311
|
+
return EXIT_OK
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def cmd_plus_me(_args) -> int:
|
|
315
|
+
client = _client()
|
|
316
|
+
_emit(client.request("GET", "/api/ext/v1/accounts"))
|
|
317
|
+
return EXIT_OK
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _client(args=None) -> Client:
|
|
321
|
+
base_url = _resolve_base_url(getattr(args, "base_url", None))
|
|
322
|
+
key = _resolve_key(getattr(args, "api_key", None))
|
|
323
|
+
return Client(base_url, key)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ── emails ──────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
def _email_list_params(args) -> dict:
|
|
329
|
+
params: dict = {"limit": args.limit, "offset": args.offset}
|
|
330
|
+
if args.account_id is not None:
|
|
331
|
+
params["account_id"] = args.account_id
|
|
332
|
+
if args.folder:
|
|
333
|
+
params["folder"] = args.folder
|
|
334
|
+
if getattr(args, "q", None):
|
|
335
|
+
params["q"] = args.q
|
|
336
|
+
if args.is_read is not None:
|
|
337
|
+
params["is_read"] = "true" if args.is_read else "false"
|
|
338
|
+
if args.starred is not None:
|
|
339
|
+
params["starred"] = "true" if args.starred else "false"
|
|
340
|
+
if args.category:
|
|
341
|
+
params["category"] = args.category
|
|
342
|
+
if args.sender:
|
|
343
|
+
params["sender"] = args.sender
|
|
344
|
+
if args.recipient:
|
|
345
|
+
params["recipient"] = args.recipient
|
|
346
|
+
if args.after:
|
|
347
|
+
params["after"] = args.after
|
|
348
|
+
if args.before:
|
|
349
|
+
params["before"] = args.before
|
|
350
|
+
if args.has_attachments is not None:
|
|
351
|
+
params["has_attachments"] = "true" if args.has_attachments else "false"
|
|
352
|
+
return params
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_emails_list(args) -> int:
|
|
356
|
+
client = _client(args)
|
|
357
|
+
_emit(client.request("GET", "/api/ext/v1/emails", params=_email_list_params(args)))
|
|
358
|
+
return EXIT_OK
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cmd_emails_search(args) -> int:
|
|
362
|
+
args.q = args.query
|
|
363
|
+
return cmd_emails_list(args)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_emails_read(args) -> int:
|
|
367
|
+
client = _client(args)
|
|
368
|
+
detail = client.request("GET", f"/api/ext/v1/emails/{args.email_id}")
|
|
369
|
+
if args.save_attachments:
|
|
370
|
+
out_dir = Path(args.save_attachments)
|
|
371
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
saved = []
|
|
373
|
+
for att in detail.get("attachments", []):
|
|
374
|
+
content = client.download(
|
|
375
|
+
f"/api/ext/v1/emails/{args.email_id}/attachments/{att['id']}")
|
|
376
|
+
target = out_dir / Path(att["filename"] or f"attachment-{att['id']}").name
|
|
377
|
+
n = 1
|
|
378
|
+
while target.exists():
|
|
379
|
+
target = out_dir / f"{target.stem}-{n}{target.suffix}"
|
|
380
|
+
n += 1
|
|
381
|
+
target.write_bytes(content)
|
|
382
|
+
saved.append(str(target))
|
|
383
|
+
detail = dict(detail, saved_to=saved)
|
|
384
|
+
_stderr(f"已保存 {len(saved)} 个附件到 {out_dir}")
|
|
385
|
+
_emit(detail)
|
|
386
|
+
return EXIT_OK
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def cmd_emails_action(args) -> int:
|
|
390
|
+
client = _client(args)
|
|
391
|
+
ids = [int(x) for x in args.ids.split(",") if x.strip()]
|
|
392
|
+
body = client.request("POST", "/api/ext/v1/emails/actions", json_body={
|
|
393
|
+
"ids": ids, "action": args.action,
|
|
394
|
+
**({"folder": args.folder} if args.folder else {}),
|
|
395
|
+
})
|
|
396
|
+
# 服务器移动类异步:轮询 job 至终态(≤60s),agent 拿到的是确定结果
|
|
397
|
+
if body.get("job_id"):
|
|
398
|
+
deadline = time.monotonic() + 60
|
|
399
|
+
while time.monotonic() < deadline:
|
|
400
|
+
job = client.request("GET", f"/api/ext/v1/jobs/{body['job_id']}")
|
|
401
|
+
if job.get("status") in ("done", "failed"):
|
|
402
|
+
body = dict(body, job=job)
|
|
403
|
+
break
|
|
404
|
+
time.sleep(1)
|
|
405
|
+
else:
|
|
406
|
+
body = dict(body, job={"status": "timeout", "job_id": body["job_id"]})
|
|
407
|
+
_stderr("任务仍在后台进行(60s 未到终态),可稍后 jobs get 查询")
|
|
408
|
+
_emit(body)
|
|
409
|
+
return EXIT_OK
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_jobs_get(args) -> int:
|
|
413
|
+
client = _client(args)
|
|
414
|
+
_emit(client.request("GET", f"/api/ext/v1/jobs/{args.job_id}"))
|
|
415
|
+
return EXIT_OK
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ── drafts ──────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
def _body_kwargs(args) -> dict:
|
|
421
|
+
"""--body / --body-file + --body-format → 服务端三选一字段。"""
|
|
422
|
+
if args.body and args.body_file:
|
|
423
|
+
raise CliError("--body 与 --body-file 只能二选一", code="bad_request",
|
|
424
|
+
exit_code=EXIT_BAD_PARAMS)
|
|
425
|
+
field = {"md": "body_md", "html": "body_html", "text": "body_text"}[args.body_format]
|
|
426
|
+
text = args.body
|
|
427
|
+
if args.body_file:
|
|
428
|
+
text = Path(args.body_file).read_text(encoding="utf-8")
|
|
429
|
+
return {field: text or ""}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _upload_attachments(client: Client, draft_id: int, paths: list[str]) -> None:
|
|
433
|
+
files = [("files", (Path(p).name, Path(p).read_bytes(), "application/octet-stream"))
|
|
434
|
+
for p in paths]
|
|
435
|
+
client.request("POST", f"/api/ext/v1/drafts/{draft_id}/attachments", files=files)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _refresh_draft(client: Client, draft_id: int) -> dict:
|
|
439
|
+
return client.request("GET", f"/api/ext/v1/drafts/{draft_id}")["draft"]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cmd_drafts_create(args) -> int:
|
|
443
|
+
client = _client(args)
|
|
444
|
+
payload = {"account_id": args.account_id, "to": args.to or "", "cc": args.cc or "",
|
|
445
|
+
"bcc": args.bcc or "", "subject": args.subject or "", **_body_kwargs(args)}
|
|
446
|
+
draft = client.request("POST", "/api/ext/v1/drafts", json_body=payload)["draft"]
|
|
447
|
+
if args.attachment:
|
|
448
|
+
_upload_attachments(client, draft["id"], args.attachment)
|
|
449
|
+
draft = _refresh_draft(client, draft["id"])
|
|
450
|
+
_emit(draft)
|
|
451
|
+
return EXIT_OK
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_drafts_reply(args) -> int:
|
|
455
|
+
client = _client(args)
|
|
456
|
+
payload = {"email_id": args.email_id, "reply_all": args.reply_all,
|
|
457
|
+
"cc": args.cc or "", "bcc": args.bcc or "", **_body_kwargs(args)}
|
|
458
|
+
draft = client.request("POST", "/api/ext/v1/drafts/reply", json_body=payload)["draft"]
|
|
459
|
+
if args.attachment:
|
|
460
|
+
_upload_attachments(client, draft["id"], args.attachment)
|
|
461
|
+
draft = _refresh_draft(client, draft["id"])
|
|
462
|
+
_emit(draft)
|
|
463
|
+
return EXIT_OK
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def cmd_drafts_forward(args) -> int:
|
|
467
|
+
client = _client(args)
|
|
468
|
+
payload = {"email_id": args.email_id, "to": args.to or "",
|
|
469
|
+
"include_attachments": args.include_attachments,
|
|
470
|
+
"cc": args.cc or "", "bcc": args.bcc or "", **_body_kwargs(args)}
|
|
471
|
+
draft = client.request("POST", "/api/ext/v1/drafts/forward", json_body=payload)["draft"]
|
|
472
|
+
if args.attachment:
|
|
473
|
+
_upload_attachments(client, draft["id"], args.attachment)
|
|
474
|
+
draft = _refresh_draft(client, draft["id"])
|
|
475
|
+
_emit(draft)
|
|
476
|
+
return EXIT_OK
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _strip_tags(html_text: str) -> str:
|
|
480
|
+
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html_text or "")).strip()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def cmd_drafts_send(args) -> int:
|
|
484
|
+
client = _client(args)
|
|
485
|
+
if not args.confirmed:
|
|
486
|
+
# 阶段一:拉草稿打摘要 → exit 8,agent 必须停下等用户许可
|
|
487
|
+
draft = _refresh_draft(client, args.draft_id)
|
|
488
|
+
preview = _strip_tags(draft.get("body_html") or "")
|
|
489
|
+
raise CliError("需要两阶段确认:把 summary 展示给用户并取得明确许可,"
|
|
490
|
+
"然后原参数加 --confirmed 重放;不得在同一轮自行确认。",
|
|
491
|
+
code="confirmation_required", exit_code=EXIT_CONFIRM,
|
|
492
|
+
extra={"summary": {
|
|
493
|
+
"draft_id": draft["id"], "to": draft.get("to_addrs", ""),
|
|
494
|
+
"cc": draft.get("cc_addrs", ""), "bcc": draft.get("bcc_addrs", ""),
|
|
495
|
+
"subject": draft.get("subject", ""),
|
|
496
|
+
"body_preview": preview[:200],
|
|
497
|
+
"attachments": [a["filename"] for a in draft.get("attachments", [])],
|
|
498
|
+
}})
|
|
499
|
+
client.request("POST", f"/api/ext/v1/drafts/{args.draft_id}/approve")
|
|
500
|
+
_emit({"sent": True, "draft_id": args.draft_id})
|
|
501
|
+
return EXIT_OK
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ── 其他 ────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
def cmd_contacts_search(args) -> int:
|
|
507
|
+
client = _client(args)
|
|
508
|
+
_emit(client.request("GET", "/api/ext/v1/contacts",
|
|
509
|
+
params={"q": args.query or "", "limit": args.limit}))
|
|
510
|
+
return EXIT_OK
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def cmd_folders_list(args) -> int:
|
|
514
|
+
client = _client(args)
|
|
515
|
+
data = client.request("GET", "/api/ext/v1/folders",
|
|
516
|
+
params={"account_id": args.account_id})
|
|
517
|
+
_emit(data)
|
|
518
|
+
return EXIT_OK
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _agent_collect(out: dict) -> dict:
|
|
522
|
+
"""非流式 agent 响应 → 紧凑 envelope:回答 + 待审批 + 暂停态(供 SKILL.md 决策)。"""
|
|
523
|
+
events = out.get("events") or []
|
|
524
|
+
paused = None
|
|
525
|
+
for ev in reversed(events):
|
|
526
|
+
if ev.get("type") == "paused" and ev.get("reason") != "approval":
|
|
527
|
+
paused = {"reason": ev.get("reason"), "run_id": ev.get("run_id")}
|
|
528
|
+
break
|
|
529
|
+
return {"answer": out.get("answer") or "",
|
|
530
|
+
"approvals": out.get("approvals") or [],
|
|
531
|
+
"paused": paused}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def cmd_agent_ask(args) -> int:
|
|
535
|
+
client = _client(args)
|
|
536
|
+
payload = {"question": args.question, "mode": args.mode,
|
|
537
|
+
"account_ids": args.account_id or []}
|
|
538
|
+
if args.session_id is not None:
|
|
539
|
+
payload["session_id"] = args.session_id
|
|
540
|
+
out = _agent_collect(client.request("POST", "/api/ext/v1/agent/chat", json_body=payload))
|
|
541
|
+
_emit(out)
|
|
542
|
+
return EXIT_OK
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_agent_decide(args) -> int:
|
|
546
|
+
if args.approve == args.reject:
|
|
547
|
+
raise CliError("--approve 与 --reject 必须二选一",
|
|
548
|
+
code="invalid_params", exit_code=EXIT_BAD_PARAMS)
|
|
549
|
+
client = _client(args)
|
|
550
|
+
decision = "approve" if args.approve else "reject"
|
|
551
|
+
out = client.request("POST", f"/api/ext/v1/agent/actions/{args.action_id}/decide",
|
|
552
|
+
json_body={"decision": decision})
|
|
553
|
+
_emit(out)
|
|
554
|
+
return EXIT_OK
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def cmd_agent_resume(args) -> int:
|
|
558
|
+
client = _client(args)
|
|
559
|
+
payload: dict = {"run_id": args.run_id}
|
|
560
|
+
if args.answer:
|
|
561
|
+
payload["answer"] = args.answer
|
|
562
|
+
out = _agent_collect(client.request("POST", "/api/ext/v1/agent/resume", json_body=payload))
|
|
563
|
+
_emit(out)
|
|
564
|
+
return EXIT_OK
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def cmd_digest(args) -> int:
|
|
568
|
+
client = _client(args)
|
|
569
|
+
_emit(client.request("GET", "/api/ext/v1/digest"))
|
|
570
|
+
return EXIT_OK
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def cmd_watch(args) -> int:
|
|
574
|
+
client = _client(args)
|
|
575
|
+
params: dict = {"limit": 200}
|
|
576
|
+
if args.account_id is not None:
|
|
577
|
+
params["account_id"] = args.account_id
|
|
578
|
+
if args.since_id is None:
|
|
579
|
+
latest = client.request("GET", "/api/ext/v1/emails/recent", params={"limit": 1})
|
|
580
|
+
params["since_id"] = latest["latest_id"] # 基线:不回放历史
|
|
581
|
+
else:
|
|
582
|
+
params["since_id"] = args.since_id
|
|
583
|
+
_stderr(f"watching(base={params['since_id']},Ctrl-C 停止)…")
|
|
584
|
+
while True:
|
|
585
|
+
body = client.request("GET", "/api/ext/v1/emails/recent", params=params)
|
|
586
|
+
for item in body["items"]:
|
|
587
|
+
_emit(item)
|
|
588
|
+
params["since_id"] = body["latest_id"]
|
|
589
|
+
time.sleep(args.interval)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ── 参数表 ──────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
def str2bool(v: str) -> bool:
|
|
595
|
+
if v.lower() in ("true", "1", "yes", "y"):
|
|
596
|
+
return True
|
|
597
|
+
if v.lower() in ("false", "0", "no", "n"):
|
|
598
|
+
return False
|
|
599
|
+
raise argparse.ArgumentTypeError(f"需为 true/false:{v}")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _add_auth_base(p: argparse.ArgumentParser) -> None:
|
|
603
|
+
p.add_argument("--base-url", help=f"Nmail 地址(默认 {DEFAULT_BASE_URL};env NMAIL_BASE_URL)")
|
|
604
|
+
p.add_argument("--api-key", help="直接指定 Key(env NMAIL_API_KEY)")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _add_email_filters(p: argparse.ArgumentParser) -> None:
|
|
608
|
+
p.add_argument("--account-id", type=int)
|
|
609
|
+
p.add_argument("--folder")
|
|
610
|
+
p.add_argument("--is-read", type=str2bool, default=None, metavar="true|false")
|
|
611
|
+
p.add_argument("--starred", type=str2bool, default=None, metavar="true|false")
|
|
612
|
+
p.add_argument("--category")
|
|
613
|
+
p.add_argument("--from", dest="sender", help="发件人过滤(地址或姓名包含匹配)")
|
|
614
|
+
p.add_argument("--to", dest="recipient", help="收件人过滤(包含匹配)")
|
|
615
|
+
p.add_argument("--after", help="起始日期 YYYY-MM-DD(含当天)")
|
|
616
|
+
p.add_argument("--before", help="截止日期 YYYY-MM-DD(含当天)")
|
|
617
|
+
p.add_argument("--has-attachments", type=str2bool, default=None, metavar="true|false")
|
|
618
|
+
p.add_argument("--limit", type=int, default=50)
|
|
619
|
+
p.add_argument("--offset", type=int, default=0)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _add_body(p: argparse.ArgumentParser) -> None:
|
|
623
|
+
p.add_argument("--body", help="正文(与 --body-file 二选一)")
|
|
624
|
+
p.add_argument("--body-file", help="正文文件路径(免 shell 转义,推荐)")
|
|
625
|
+
p.add_argument("--body-format", choices=["md", "html", "text"], default="md",
|
|
626
|
+
help="--body/--body-file 的格式(默认 md)")
|
|
627
|
+
p.add_argument("--attachment", action="append", metavar="PATH", help="附件路径(可重复)")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
631
|
+
import nmail_cli
|
|
632
|
+
|
|
633
|
+
parser = argparse.ArgumentParser(
|
|
634
|
+
prog="nmail-cli",
|
|
635
|
+
description="Nmail 对外 API 客户端(agent/skill 友好:JSON envelope + exit code)")
|
|
636
|
+
parser.add_argument("--version", action="version", version=f"nmail-cli {nmail_cli.__version__}")
|
|
637
|
+
|
|
638
|
+
sub = parser.add_subparsers(dest="command")
|
|
639
|
+
|
|
640
|
+
auth = sub.add_parser("auth", help="配对/状态/登出")
|
|
641
|
+
auth_sub = auth.add_subparsers(dest="auth_command", required=True)
|
|
642
|
+
login = auth_sub.add_parser("login", help="本机自动配对建 Key;远程 --base-url + --key 粘贴")
|
|
643
|
+
login.add_argument("--base-url", default=None)
|
|
644
|
+
login.add_argument("--key", default=None, help="远程模式粘贴的 API Key")
|
|
645
|
+
login.add_argument("--scopes", default="read", help="本机配对的 scope,逗号分隔(默认 read)")
|
|
646
|
+
login.add_argument("--yes", action="store_true", help="对外 API 未启用时直接打开(免确认)")
|
|
647
|
+
login.set_defaults(func=cmd_auth_login)
|
|
648
|
+
status = auth_sub.add_parser("status", help="查看配置与账号可达性")
|
|
649
|
+
status.set_defaults(func=cmd_auth_status)
|
|
650
|
+
out = auth_sub.add_parser("logout", help="清除本机保存的配置")
|
|
651
|
+
out.set_defaults(func=cmd_auth_logout)
|
|
652
|
+
|
|
653
|
+
me = sub.add_parser("+me", help="当前账号列表与健康")
|
|
654
|
+
_add_auth_base(me)
|
|
655
|
+
me.set_defaults(func=cmd_plus_me)
|
|
656
|
+
|
|
657
|
+
emails = sub.add_parser("emails", help="邮件列表/搜索/读取/动作")
|
|
658
|
+
emails_sub = emails.add_subparsers(dest="emails_command", required=True)
|
|
659
|
+
lst = emails_sub.add_parser("list", help="列表/过滤")
|
|
660
|
+
_add_email_filters(lst)
|
|
661
|
+
_add_auth_base(lst)
|
|
662
|
+
lst.set_defaults(func=cmd_emails_list)
|
|
663
|
+
sch = emails_sub.add_parser("search", help="关键词搜索(其余过滤同 list)")
|
|
664
|
+
sch.add_argument("query", help="关键词(≥3 字走全文检索)")
|
|
665
|
+
_add_email_filters(sch)
|
|
666
|
+
_add_auth_base(sch)
|
|
667
|
+
sch.set_defaults(func=cmd_emails_search)
|
|
668
|
+
read = emails_sub.add_parser("read", help="读取邮件详情")
|
|
669
|
+
read.add_argument("email_id", type=int)
|
|
670
|
+
read.add_argument("--save-attachments", metavar="DIR", help="保存附件到目录(相对路径)")
|
|
671
|
+
_add_auth_base(read)
|
|
672
|
+
read.set_defaults(func=cmd_emails_read)
|
|
673
|
+
act = emails_sub.add_parser("action", help="批量动作(移动类自动轮询到终态)")
|
|
674
|
+
act.add_argument("--ids", required=True, help="逗号分隔邮件 id")
|
|
675
|
+
act.add_argument("--action", required=True,
|
|
676
|
+
choices=["read", "unread", "star", "unstar", "archive", "unarchive",
|
|
677
|
+
"trash", "move"])
|
|
678
|
+
act.add_argument("--folder", help="action=move 的目标文件夹")
|
|
679
|
+
_add_auth_base(act)
|
|
680
|
+
act.set_defaults(func=cmd_emails_action)
|
|
681
|
+
|
|
682
|
+
drafts = sub.add_parser("drafts", help="草稿创建/回复/转发/发送")
|
|
683
|
+
drafts_sub = drafts.add_subparsers(dest="drafts_command", required=True)
|
|
684
|
+
create = drafts_sub.add_parser("create", help="新建草稿")
|
|
685
|
+
create.add_argument("--account-id", type=int, required=True)
|
|
686
|
+
create.add_argument("--to")
|
|
687
|
+
create.add_argument("--cc")
|
|
688
|
+
create.add_argument("--bcc")
|
|
689
|
+
create.add_argument("--subject")
|
|
690
|
+
_add_body(create)
|
|
691
|
+
_add_auth_base(create)
|
|
692
|
+
create.set_defaults(func=cmd_drafts_create)
|
|
693
|
+
reply = drafts_sub.add_parser("reply", help="回复:自动 Re: 主题/收件人/引用块")
|
|
694
|
+
reply.add_argument("--email-id", type=int, required=True)
|
|
695
|
+
reply.add_argument("--reply-all", action="store_true", help="原收件人并入 cc")
|
|
696
|
+
reply.add_argument("--cc")
|
|
697
|
+
reply.add_argument("--bcc")
|
|
698
|
+
_add_body(reply)
|
|
699
|
+
_add_auth_base(reply)
|
|
700
|
+
reply.set_defaults(func=cmd_drafts_reply)
|
|
701
|
+
fwd = drafts_sub.add_parser("forward", help="转发:自动 Fwd: 主题/引用块")
|
|
702
|
+
fwd.add_argument("--email-id", type=int, required=True)
|
|
703
|
+
fwd.add_argument("--to", required=True, help="转发收件人(逗号分隔)")
|
|
704
|
+
fwd.add_argument("--include-attachments", action="store_true", help="复制原邮件附件")
|
|
705
|
+
fwd.add_argument("--cc")
|
|
706
|
+
fwd.add_argument("--bcc")
|
|
707
|
+
_add_body(fwd)
|
|
708
|
+
_add_auth_base(fwd)
|
|
709
|
+
fwd.set_defaults(func=cmd_drafts_forward)
|
|
710
|
+
send = drafts_sub.add_parser("send", help="发送草稿(两阶段:先不带 --confirmed 拿摘要)")
|
|
711
|
+
send.add_argument("draft_id", type=int)
|
|
712
|
+
send.add_argument("--confirmed", action="store_true",
|
|
713
|
+
help="第二阶段:用户明确许可后才传(不得同轮自确认)")
|
|
714
|
+
_add_auth_base(send)
|
|
715
|
+
send.set_defaults(func=cmd_drafts_send)
|
|
716
|
+
|
|
717
|
+
contacts = sub.add_parser("contacts", help="通讯录")
|
|
718
|
+
contacts_sub = contacts.add_subparsers(dest="contacts_command", required=True)
|
|
719
|
+
cs = contacts_sub.add_parser("search", help="搜索联系人")
|
|
720
|
+
cs.add_argument("query", nargs="?", default="")
|
|
721
|
+
cs.add_argument("--limit", type=int, default=50)
|
|
722
|
+
_add_auth_base(cs)
|
|
723
|
+
cs.set_defaults(func=cmd_contacts_search)
|
|
724
|
+
|
|
725
|
+
dig = sub.add_parser("digest", help="最新每日摘要")
|
|
726
|
+
_add_auth_base(dig)
|
|
727
|
+
dig.set_defaults(func=cmd_digest)
|
|
728
|
+
|
|
729
|
+
folders = sub.add_parser("folders", help="账号文件夹列表")
|
|
730
|
+
folders_sub = folders.add_subparsers(dest="folders_command", required=True)
|
|
731
|
+
fl = folders_sub.add_parser("list", help="列出账号的文件夹(move/--folder 过滤的目标名从此查)")
|
|
732
|
+
fl.add_argument("--account-id", type=int, required=True, help="账号 id(+me 查看)")
|
|
733
|
+
_add_auth_base(fl)
|
|
734
|
+
fl.set_defaults(func=cmd_folders_list)
|
|
735
|
+
|
|
736
|
+
agent_ = sub.add_parser("agent", help="内置总管家(scope=agent;AI 需已在 Nmail 配置)")
|
|
737
|
+
agent_sub = agent_.add_subparsers(dest="agent_command", required=True)
|
|
738
|
+
ask = agent_sub.add_parser("ask", help="委托总管家处理一件事(写动作会返回待审批清单)")
|
|
739
|
+
ask.add_argument("question", help="要交办的事(自然语言)")
|
|
740
|
+
ask.add_argument("--mode", choices=["approval", "auto"], default="approval",
|
|
741
|
+
help="approval=写动作逐条待审批(默认),auto=安全约束内直执行")
|
|
742
|
+
ask.add_argument("--account-id", type=int, action="append",
|
|
743
|
+
help="限定账号 id(可重复;缺省=全部账号)")
|
|
744
|
+
ask.add_argument("--session-id", type=int, help="延续既有会话(多轮交办)")
|
|
745
|
+
_add_auth_base(ask)
|
|
746
|
+
ask.set_defaults(func=cmd_agent_ask)
|
|
747
|
+
dec = agent_sub.add_parser("decide", help="批准/拒绝总管家的待审批动作")
|
|
748
|
+
dec.add_argument("action_id", type=int)
|
|
749
|
+
dec.add_argument("--approve", action="store_true", help="批准执行")
|
|
750
|
+
dec.add_argument("--reject", action="store_true", help="拒绝(总管家会改道)")
|
|
751
|
+
_add_auth_base(dec)
|
|
752
|
+
dec.set_defaults(func=cmd_agent_decide)
|
|
753
|
+
res = agent_sub.add_parser("resume", help="续跑运行(审批决定后/触顶暂停后/澄清回答后)")
|
|
754
|
+
res.add_argument("run_id", type=int)
|
|
755
|
+
res.add_argument("--answer", help="ask_user 澄清问题的回答(waiting_input 时必传)")
|
|
756
|
+
_add_auth_base(res)
|
|
757
|
+
res.set_defaults(func=cmd_agent_resume)
|
|
758
|
+
|
|
759
|
+
wat = sub.add_parser("watch", help="新邮件流(NDJSON 每行一封,Ctrl-C 停止)")
|
|
760
|
+
wat.add_argument("--since-id", type=int, default=None,
|
|
761
|
+
help="起始游标(默认当前最新,不回放历史)")
|
|
762
|
+
wat.add_argument("--account-id", type=int)
|
|
763
|
+
wat.add_argument("--interval", type=float, default=10.0, help="轮询秒数(默认 10)")
|
|
764
|
+
_add_auth_base(wat)
|
|
765
|
+
wat.set_defaults(func=cmd_watch)
|
|
766
|
+
|
|
767
|
+
jobs = sub.add_parser("jobs", help="后台任务进度")
|
|
768
|
+
jobs_sub = jobs.add_subparsers(dest="jobs_command", required=True)
|
|
769
|
+
jg = jobs_sub.add_parser("get", help="查询单个任务")
|
|
770
|
+
jg.add_argument("job_id", type=int)
|
|
771
|
+
_add_auth_base(jg)
|
|
772
|
+
jg.set_defaults(func=cmd_jobs_get)
|
|
773
|
+
|
|
774
|
+
return parser
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nmail-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Nmail 对外 API 命令行客户端——为 agent/skill 设计(JSON envelope + exit code 契约 + 两阶段确认)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Nmail" }]
|
|
13
|
+
dependencies = ["requests>=2.31"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
nmail-cli = "nmail_cli.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["nmail_cli"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""nmail-cli 测试夹具:复用后端一次性临时数据目录,Client 走 ASGI TestClient 传输。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
10
|
+
sys.path.insert(0, str(REPO_ROOT / "backend"))
|
|
11
|
+
sys.path.insert(0, str(REPO_ROOT / "nmail-cli"))
|
|
12
|
+
|
|
13
|
+
os.environ.setdefault("NMAIL_DATA_DIR", tempfile.mkdtemp(prefix="nmail-cli-pytest-"))
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""nmail-cli 契约测试:envelope/exit code、两阶段确认、配置、命令链路。
|
|
2
|
+
|
|
3
|
+
Client 以 ASGI TestClient 为传输(DEFAULT_TRANSPORT 注入),全链路打到真实
|
|
4
|
+
FastAPI app(与 backend/tests 同一夹具口径);不发真实邮件——send --confirmed
|
|
5
|
+
路径以无 SMTP 凭据的上游失败(exit 1)验证到达 outbox。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from fastapi.testclient import TestClient
|
|
14
|
+
|
|
15
|
+
from app.db import database
|
|
16
|
+
from app.main import app
|
|
17
|
+
|
|
18
|
+
from nmail_cli import cli
|
|
19
|
+
from nmail_cli.cli import (
|
|
20
|
+
EXIT_AUTH,
|
|
21
|
+
EXIT_BAD_PARAMS,
|
|
22
|
+
EXIT_CONFIRM,
|
|
23
|
+
EXIT_OK,
|
|
24
|
+
EXIT_PERMANENT,
|
|
25
|
+
EXIT_UPSTREAM,
|
|
26
|
+
Client,
|
|
27
|
+
CliError,
|
|
28
|
+
_config_path,
|
|
29
|
+
_load_config,
|
|
30
|
+
main,
|
|
31
|
+
save_config,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
client = TestClient(app, base_url="http://127.0.0.1")
|
|
35
|
+
database.run_migrations() # TestClient 不触发 lifespan,迁移在此显式补跑
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(autouse=True)
|
|
39
|
+
def _asgi_transport(monkeypatch, tmp_path):
|
|
40
|
+
"""全部命令走 ASGI 传输;配置目录指向临时目录。"""
|
|
41
|
+
def transport(method, path, headers, json_body=None, files=None, params=None):
|
|
42
|
+
r = client.request(method, path, headers=headers, json=json_body,
|
|
43
|
+
files=files, params=params)
|
|
44
|
+
try:
|
|
45
|
+
body = r.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
body = {}
|
|
48
|
+
return r.status_code, body, r.content
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(cli, "DEFAULT_TRANSPORT", transport)
|
|
51
|
+
monkeypatch.setenv("NMAIL_CLI_CONFIG_DIR", str(tmp_path / "cfg"))
|
|
52
|
+
monkeypatch.delenv("NMAIL_BASE_URL", raising=False)
|
|
53
|
+
monkeypatch.delenv("NMAIL_API_KEY", raising=False)
|
|
54
|
+
yield
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _out_lines(capsys) -> list:
|
|
58
|
+
return [json.loads(line) for line in capsys.readouterr().out.strip().splitlines()]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _make_key(scopes: list[str]) -> str:
|
|
62
|
+
resp = client.post("/api/extkeys", json={"name": "cli-test", "scopes": scopes})
|
|
63
|
+
assert resp.status_code == 200
|
|
64
|
+
return resp.json()["key"]["key"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _seed_account() -> int:
|
|
68
|
+
conn = database.get_conn()
|
|
69
|
+
cur = conn.execute(
|
|
70
|
+
"INSERT INTO accounts (email, imap_server, imap_port) VALUES (?, 'imap.test', 993)",
|
|
71
|
+
(f"c{uuid.uuid4().hex[:8]}@example.com",),
|
|
72
|
+
)
|
|
73
|
+
conn.commit()
|
|
74
|
+
return int(cur.lastrowid)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _seed_email(account_id: int, uid: int, subject: str, **extra) -> int:
|
|
78
|
+
conn = database.get_conn()
|
|
79
|
+
cols = {"account_id": account_id, "folder": "INBOX", "uid": uid, "subject": subject,
|
|
80
|
+
"sender_email": "boss@example.com", "body_text": "原始正文"}
|
|
81
|
+
cols.update(extra)
|
|
82
|
+
cur = conn.execute(
|
|
83
|
+
f"INSERT INTO emails ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})",
|
|
84
|
+
tuple(cols.values()),
|
|
85
|
+
)
|
|
86
|
+
conn.commit()
|
|
87
|
+
return int(cur.lastrowid)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cleanup_drafts(account_id: int) -> None:
|
|
91
|
+
for d in client.get("/api/user-drafts", params={"status": "editing"}).json()["drafts"]:
|
|
92
|
+
if d["account_id"] == account_id:
|
|
93
|
+
client.delete(f"/api/user-drafts/{d['id']}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── 单元:契约映射 ──────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def test_错误映射():
|
|
99
|
+
e = Client._error(401, {"error": {"code": "invalid_key", "message": "x"}})
|
|
100
|
+
assert e.exit_code == EXIT_AUTH
|
|
101
|
+
e = Client._error(404, {"error": {"code": "not_found", "message": "x"}})
|
|
102
|
+
assert e.exit_code == EXIT_PERMANENT
|
|
103
|
+
e = Client._error(502, {"error": {"code": "upstream", "message": "x"}})
|
|
104
|
+
assert e.exit_code == EXIT_UPSTREAM
|
|
105
|
+
e = Client._error(422, {"detail": [{"loc": ["query", "limit"], "msg": "bad"}]})
|
|
106
|
+
assert e.exit_code == EXIT_UPSTREAM # 非 envelope 的 422 视为服务器侧异常形态
|
|
107
|
+
assert e.exit_code == Client._error(500, {}).exit_code
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_is_local判定():
|
|
111
|
+
assert cli._is_local("http://127.0.0.1:8720")
|
|
112
|
+
assert cli._is_local("http://localhost:8720")
|
|
113
|
+
assert not cli._is_local("https://nmail.example.com")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_配置读写与权限(tmp_path):
|
|
117
|
+
path = save_config("http://127.0.0.1:9999", "nmail_k")
|
|
118
|
+
assert json.loads(path.read_text())["key"] == "nmail_k"
|
|
119
|
+
assert path.stat().st_mode & 0o777 == 0o600
|
|
120
|
+
assert _load_config()["base_url"] == "http://127.0.0.1:9999"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_未配置时exit3(capsys):
|
|
124
|
+
assert main(["+me"]) == EXIT_AUTH
|
|
125
|
+
body = _out_lines(capsys)[0]
|
|
126
|
+
assert body["ok"] is False and body["error"]["code"] == "invalid_key"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── 集成:真实 app 链路 ─────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def test_auth_login本机自动配对(capsys):
|
|
132
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
133
|
+
rc = main(["auth", "login", "--base-url", "http://127.0.0.1:8720", "--yes"])
|
|
134
|
+
assert rc == EXIT_OK
|
|
135
|
+
envelope = _out_lines(capsys)[0]
|
|
136
|
+
assert envelope["ok"] is True and envelope["data"]["key"].startswith("nmail_")
|
|
137
|
+
cfg = _load_config()
|
|
138
|
+
assert cfg["base_url"] == "http://127.0.0.1:8720"
|
|
139
|
+
assert cfg["key"] == envelope["data"]["key"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_emails_list过滤与read(capsys):
|
|
143
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
144
|
+
save_config("http://127.0.0.1:8720", _make_key(["read"]))
|
|
145
|
+
aid = _seed_account()
|
|
146
|
+
e1 = _seed_email(aid, 1, "张三的周报", sender_email="zhangsan@example.com")
|
|
147
|
+
|
|
148
|
+
assert main(["emails", "list", "--account-id", str(aid), "--from", "zhangsan@",
|
|
149
|
+
"--limit", "10"]) == EXIT_OK
|
|
150
|
+
items = _out_lines(capsys)[0]["data"]["items"]
|
|
151
|
+
assert [i["id"] for i in items] == [e1]
|
|
152
|
+
|
|
153
|
+
assert main(["emails", "read", str(e1)]) == EXIT_OK
|
|
154
|
+
detail = _out_lines(capsys)[0]["data"]
|
|
155
|
+
assert detail["subject"] == "张三的周报" and detail["body_text"] == "原始正文"
|
|
156
|
+
|
|
157
|
+
assert main(["emails", "read", "999999"]) == EXIT_PERMANENT
|
|
158
|
+
assert _out_lines(capsys)[0]["error"]["code"] == "not_found"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_drafts全链路与两阶段确认(capsys, tmp_path):
|
|
162
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
163
|
+
save_config("http://127.0.0.1:8720", _make_key(["read", "write", "send"]))
|
|
164
|
+
aid = _seed_account()
|
|
165
|
+
eid = _seed_email(aid, 1, "项目排期")
|
|
166
|
+
body_file = tmp_path / "reply.md"
|
|
167
|
+
body_file.write_text("**收到**,明天回复。", encoding="utf-8")
|
|
168
|
+
|
|
169
|
+
# 回复(--body-file md)
|
|
170
|
+
assert main(["drafts", "reply", "--email-id", str(eid),
|
|
171
|
+
"--body-file", str(body_file)]) == EXIT_OK
|
|
172
|
+
draft = _out_lines(capsys)[0]["data"]
|
|
173
|
+
assert draft["mode"] == "reply" and draft["to_addrs"] == "boss@example.com"
|
|
174
|
+
assert "<strong>收到</strong>" in draft["body_html"]
|
|
175
|
+
|
|
176
|
+
# 阶段一:无 --confirmed → exit 8 + summary
|
|
177
|
+
assert main(["drafts", "send", str(draft["id"])]) == EXIT_CONFIRM
|
|
178
|
+
fail = _out_lines(capsys)[0]
|
|
179
|
+
assert fail["ok"] is False and fail["error"]["code"] == "confirmation_required"
|
|
180
|
+
summary = fail["error"]["summary"]
|
|
181
|
+
assert summary["draft_id"] == draft["id"] and summary["to"] == "boss@example.com"
|
|
182
|
+
assert "收到" in summary["body_preview"]
|
|
183
|
+
|
|
184
|
+
# 阶段二:--confirmed → 到达 outbox;测试账号无 SMTP 凭据(smtp_missing→400 业务错,
|
|
185
|
+
# exit 2)——路径已打通,真实发送由用户真机验证
|
|
186
|
+
assert main(["drafts", "send", str(draft["id"]), "--confirmed"]) == EXIT_BAD_PARAMS
|
|
187
|
+
assert _out_lines(capsys)[0]["error"]["code"] == "bad_request"
|
|
188
|
+
|
|
189
|
+
# 建稿:body 与 body-file 同给 → exit 2
|
|
190
|
+
assert main(["drafts", "create", "--account-id", str(aid), "--to", "a@b.com",
|
|
191
|
+
"--body", "x", "--body-file", str(body_file)]) == EXIT_BAD_PARAMS
|
|
192
|
+
_cleanup_drafts(aid)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_emails_action带job轮询(capsys):
|
|
196
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
197
|
+
save_config("http://127.0.0.1:8720", _make_key(["read", "write"]))
|
|
198
|
+
aid = _seed_account()
|
|
199
|
+
e = _seed_email(aid, 1, "打标")
|
|
200
|
+
assert main(["emails", "action", "--ids", str(e), "--action", "read"]) == EXIT_OK
|
|
201
|
+
body = _out_lines(capsys)[0]["data"]
|
|
202
|
+
# 打标类同步返回(无 job_id);测试账号无 IMAP 凭据 → 该账号按失败计数(服务器
|
|
203
|
+
# 打标失败的 data 层语义:ok=false + failed 计数,HTTP 200,CLI 照实透传)
|
|
204
|
+
assert "job_id" not in body and body["ok"] is False and body["failed"] == 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_watch基线不回放(capsys, monkeypatch):
|
|
208
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
209
|
+
save_config("http://127.0.0.1:8720", _make_key(["read"]))
|
|
210
|
+
aid = _seed_account()
|
|
211
|
+
_seed_email(aid, 1, "历史邮件")
|
|
212
|
+
|
|
213
|
+
# 基线后 sleep 直接打断循环(KeyboardInterrupt → exit 0)
|
|
214
|
+
monkeypatch.setattr(cli.time, "sleep", lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
215
|
+
assert main(["watch", "--account-id", str(aid), "--interval", "0"]) == EXIT_OK
|
|
216
|
+
assert _out_lines(capsys) == [] # 基线轮不输出历史邮件
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── B1 folders / B2 更新提示 / B3 agent 通道 ─────────────────
|
|
220
|
+
|
|
221
|
+
def _seed_folder(account_id: int, name: str = "INBOX") -> None:
|
|
222
|
+
conn = database.get_conn()
|
|
223
|
+
conn.execute(
|
|
224
|
+
"INSERT INTO folders (account_id, name, delim, special_use, subscribed)"
|
|
225
|
+
" VALUES (?, ?, '/', '', 1)", (account_id, name),
|
|
226
|
+
)
|
|
227
|
+
conn.commit()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_folders_list(capsys):
|
|
231
|
+
"B1:folders list 走文件夹缓存(不触发服务器 LIST)。"
|
|
232
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
233
|
+
save_config("http://127.0.0.1:8720", _make_key(["read"]))
|
|
234
|
+
aid = _seed_account()
|
|
235
|
+
_seed_folder(aid)
|
|
236
|
+
_seed_folder(aid, "Archived")
|
|
237
|
+
assert main(["folders", "list", "--account-id", str(aid)]) == EXIT_OK
|
|
238
|
+
envelope = _out_lines(capsys)[0]
|
|
239
|
+
assert envelope["ok"] is True and "folders" in envelope["data"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_agent_ask_未配置AI(capsys):
|
|
243
|
+
"B3:AI 未配置时 agent ask 返回明确错误(exit 2,agent 据此告知用户)。"
|
|
244
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
245
|
+
save_config("http://127.0.0.1:8720", _make_key(["agent"]))
|
|
246
|
+
assert main(["agent", "ask", "概况"]) == EXIT_BAD_PARAMS
|
|
247
|
+
envelope = _out_lines(capsys)[0]
|
|
248
|
+
assert envelope["ok"] is False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_agent_decide参数互斥():
|
|
252
|
+
"B3:decide 必须二选一,缺省/双选都 exit 2(不发请求)。"
|
|
253
|
+
assert main(["agent", "decide", "1"]) == EXIT_BAD_PARAMS
|
|
254
|
+
assert main(["agent", "decide", "1", "--approve", "--reject"]) == EXIT_BAD_PARAMS
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_semver比较与更新提示(monkeypatch, capsys):
|
|
258
|
+
"B2:服务端版本高于 CLI 时输出附 _notice.update。"
|
|
259
|
+
import nmail_cli
|
|
260
|
+
|
|
261
|
+
assert cli._semver_tuple("0.10.0") > cli._semver_tuple("0.9.9") > cli._semver_tuple("0.9.8")
|
|
262
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
263
|
+
save_config("http://127.0.0.1:8720", _make_key(["read"]))
|
|
264
|
+
monkeypatch.setattr(nmail_cli, "__version__", "0.0.1")
|
|
265
|
+
cli._PENDING_NOTICE.clear()
|
|
266
|
+
cli._probe_update_notice()
|
|
267
|
+
assert cli._PENDING_NOTICE["update"]["server"].startswith("0.")
|
|
268
|
+
aid = _seed_account()
|
|
269
|
+
_seed_folder(aid)
|
|
270
|
+
assert main(["folders", "list", "--account-id", str(aid)]) == EXIT_OK
|
|
271
|
+
envelope = _out_lines(capsys)[-1]
|
|
272
|
+
assert envelope["ok"] is True and envelope["_notice"]["update"]["cli"] == "0.0.1"
|