nmail-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """nmail-cli:Nmail 对外 API 命令行客户端(AGENT_SKILL_PLAN P2,REDESIGN_PLAN §19)。"""
2
+
3
+ __version__ = "0.1.0"
nmail_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from nmail_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
nmail_cli/cli.py ADDED
@@ -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,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,7 @@
1
+ nmail_cli/__init__.py,sha256=9oPktkKg9HzyvQ-xcDX3-CZTTfUfnsIDWpqNwXXIjx0,126
2
+ nmail_cli/__main__.py,sha256=rJ77CCu6dRP-SAQ5sBytQYryBjFmpfdNY1wihutxAQo,88
3
+ nmail_cli/cli.py,sha256=oiptLExSp0Pkge3L34fAKxjlF0AZCXEEbpffslqOrO0,33428
4
+ nmail_cli-0.1.0.dist-info/METADATA,sha256=oV_IPeh665TtLpsYFXkeXv3RSGoSTV4-3CIXeSd3CQ0,2353
5
+ nmail_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ nmail_cli-0.1.0.dist-info/entry_points.txt,sha256=MmVzCA4mdTSVu5Es271-rrPZKxxwfqx-u-w_RY6LL0o,49
7
+ nmail_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nmail-cli = nmail_cli.cli:main