nmail-cli 0.1.0__tar.gz → 0.4.1__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 → nmail_cli-0.4.1}/PKG-INFO +1 -1
- nmail_cli-0.4.1/nmail_cli/__init__.py +3 -0
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/nmail_cli/cli.py +52 -3
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/pyproject.toml +1 -1
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/tests/test_cli.py +55 -0
- nmail_cli-0.1.0/nmail_cli/__init__.py +0 -3
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/.gitignore +0 -0
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/README.md +0 -0
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/nmail_cli/__main__.py +0 -0
- {nmail_cli-0.1.0 → nmail_cli-0.4.1}/tests/conftest.py +0 -0
|
@@ -501,6 +501,13 @@ def cmd_drafts_send(args) -> int:
|
|
|
501
501
|
return EXIT_OK
|
|
502
502
|
|
|
503
503
|
|
|
504
|
+
def cmd_drafts_delete(args) -> int:
|
|
505
|
+
"""删除草稿(服务端仅放行 editing/discarded;在途审批草稿先 discard)。"""
|
|
506
|
+
client = _client(args)
|
|
507
|
+
_emit(client.request("DELETE", f"/api/ext/v1/drafts/{args.draft_id}"))
|
|
508
|
+
return EXIT_OK
|
|
509
|
+
|
|
510
|
+
|
|
504
511
|
# ── 其他 ────────────────────────────────────────────────────
|
|
505
512
|
|
|
506
513
|
def cmd_contacts_search(args) -> int:
|
|
@@ -518,6 +525,15 @@ def cmd_folders_list(args) -> int:
|
|
|
518
525
|
return EXIT_OK
|
|
519
526
|
|
|
520
527
|
|
|
528
|
+
def cmd_folders_sync(args) -> int:
|
|
529
|
+
"""按需同步指定文件夹(wait 模式:完成才返回,之后该文件夹立即可读)。"""
|
|
530
|
+
client = _client(args)
|
|
531
|
+
_stderr(f"同步文件夹 {args.folder}(account {args.account_id})…")
|
|
532
|
+
_emit(client.request("POST", "/api/ext/v1/folders/sync", params={
|
|
533
|
+
"account_id": args.account_id, "name": args.folder, "wait": "true"}))
|
|
534
|
+
return EXIT_OK
|
|
535
|
+
|
|
536
|
+
|
|
521
537
|
def _agent_collect(out: dict) -> dict:
|
|
522
538
|
"""非流式 agent 响应 → 紧凑 envelope:回答 + 待审批 + 暂停态(供 SKILL.md 决策)。"""
|
|
523
539
|
events = out.get("events") or []
|
|
@@ -572,6 +588,12 @@ def cmd_digest(args) -> int:
|
|
|
572
588
|
|
|
573
589
|
def cmd_watch(args) -> int:
|
|
574
590
|
client = _client(args)
|
|
591
|
+
if args.timeout is not None and args.timeout <= 0:
|
|
592
|
+
raise CliError("--timeout 必须为正数(秒)", code="bad_request",
|
|
593
|
+
exit_code=EXIT_BAD_PARAMS)
|
|
594
|
+
if args.max_emails is not None and args.max_emails <= 0:
|
|
595
|
+
raise CliError("--max-emails 必须为正整数", code="bad_request",
|
|
596
|
+
exit_code=EXIT_BAD_PARAMS)
|
|
575
597
|
params: dict = {"limit": 200}
|
|
576
598
|
if args.account_id is not None:
|
|
577
599
|
params["account_id"] = args.account_id
|
|
@@ -580,13 +602,27 @@ def cmd_watch(args) -> int:
|
|
|
580
602
|
params["since_id"] = latest["latest_id"] # 基线:不回放历史
|
|
581
603
|
else:
|
|
582
604
|
params["since_id"] = args.since_id
|
|
583
|
-
|
|
605
|
+
stop_hint = "Ctrl-C 停止" if args.timeout is None and args.max_emails is None else "到条件自动停止"
|
|
606
|
+
_stderr(f"watching(base={params['since_id']},{stop_hint})…")
|
|
607
|
+
deadline = (time.monotonic() + args.timeout) if args.timeout is not None else None
|
|
608
|
+
received = 0
|
|
584
609
|
while True:
|
|
585
610
|
body = client.request("GET", "/api/ext/v1/emails/recent", params=params)
|
|
586
611
|
for item in body["items"]:
|
|
587
612
|
_emit(item)
|
|
613
|
+
received += 1
|
|
588
614
|
params["since_id"] = body["latest_id"]
|
|
589
|
-
|
|
615
|
+
if args.max_emails is not None and received >= args.max_emails:
|
|
616
|
+
_stderr(f"已收到 {received} 封(--max-emails={args.max_emails}),停止监听")
|
|
617
|
+
return EXIT_OK
|
|
618
|
+
if deadline is not None:
|
|
619
|
+
remaining = deadline - time.monotonic()
|
|
620
|
+
if remaining <= 0:
|
|
621
|
+
_stderr(f"--timeout={args.timeout}s 到,停止监听")
|
|
622
|
+
return EXIT_OK
|
|
623
|
+
time.sleep(min(args.interval, remaining))
|
|
624
|
+
else:
|
|
625
|
+
time.sleep(args.interval)
|
|
590
626
|
|
|
591
627
|
|
|
592
628
|
# ── 参数表 ──────────────────────────────────────────────────
|
|
@@ -713,6 +749,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
713
749
|
help="第二阶段:用户明确许可后才传(不得同轮自确认)")
|
|
714
750
|
_add_auth_base(send)
|
|
715
751
|
send.set_defaults(func=cmd_drafts_send)
|
|
752
|
+
ddel = drafts_sub.add_parser("delete", help="删除草稿(仅新建/已丢弃状态;在途审批草稿先 discard)")
|
|
753
|
+
ddel.add_argument("draft_id", type=int)
|
|
754
|
+
_add_auth_base(ddel)
|
|
755
|
+
ddel.set_defaults(func=cmd_drafts_delete)
|
|
716
756
|
|
|
717
757
|
contacts = sub.add_parser("contacts", help="通讯录")
|
|
718
758
|
contacts_sub = contacts.add_subparsers(dest="contacts_command", required=True)
|
|
@@ -732,6 +772,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
732
772
|
fl.add_argument("--account-id", type=int, required=True, help="账号 id(+me 查看)")
|
|
733
773
|
_add_auth_base(fl)
|
|
734
774
|
fl.set_defaults(func=cmd_folders_list)
|
|
775
|
+
fs = folders_sub.add_parser("sync", help="按需同步指定文件夹(归档/移动后兜底;完成才返回)")
|
|
776
|
+
fs.add_argument("--account-id", type=int, required=True, help="账号 id(+me 查看)")
|
|
777
|
+
fs.add_argument("--folder", required=True, help="文件夹名(folders list 里查)")
|
|
778
|
+
_add_auth_base(fs)
|
|
779
|
+
fs.set_defaults(func=cmd_folders_sync)
|
|
735
780
|
|
|
736
781
|
agent_ = sub.add_parser("agent", help="内置总管家(scope=agent;AI 需已在 Nmail 配置)")
|
|
737
782
|
agent_sub = agent_.add_subparsers(dest="agent_command", required=True)
|
|
@@ -756,11 +801,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
756
801
|
_add_auth_base(res)
|
|
757
802
|
res.set_defaults(func=cmd_agent_resume)
|
|
758
803
|
|
|
759
|
-
wat = sub.add_parser("watch", help="新邮件流(NDJSON
|
|
804
|
+
wat = sub.add_parser("watch", help="新邮件流(NDJSON 每行一封;agent 调用建议带 --timeout)")
|
|
760
805
|
wat.add_argument("--since-id", type=int, default=None,
|
|
761
806
|
help="起始游标(默认当前最新,不回放历史)")
|
|
762
807
|
wat.add_argument("--account-id", type=int)
|
|
763
808
|
wat.add_argument("--interval", type=float, default=10.0, help="轮询秒数(默认 10)")
|
|
809
|
+
wat.add_argument("--timeout", type=float, default=None,
|
|
810
|
+
help="最长监听秒数,到点自动退出(缺省一直监听直到 Ctrl-C)")
|
|
811
|
+
wat.add_argument("--max-emails", type=int, default=None,
|
|
812
|
+
help="收到 N 封后自动退出(缺省不限)")
|
|
764
813
|
_add_auth_base(wat)
|
|
765
814
|
wat.set_defaults(func=cmd_watch)
|
|
766
815
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nmail-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.4.1" # 与 app 同一条版本线:勿手改,python3 scripts/sync_version.py 统一维护
|
|
8
8
|
description = "Nmail 对外 API 命令行客户端——为 agent/skill 设计(JSON envelope + exit code 契约 + 两阶段确认)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -239,6 +239,61 @@ def test_folders_list(capsys):
|
|
|
239
239
|
assert envelope["ok"] is True and "folders" in envelope["data"]
|
|
240
240
|
|
|
241
241
|
|
|
242
|
+
def test_drafts_delete(capsys):
|
|
243
|
+
"v0.4.1:drafts delete 删新建草稿;AI 待审(pending_review)草稿业务拒绝。"
|
|
244
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
245
|
+
save_config("http://127.0.0.1:8720", _make_key(["read", "write"]))
|
|
246
|
+
aid = _seed_account()
|
|
247
|
+
|
|
248
|
+
# 新建 → 删除 → 再读 exit 6(资源不存在)
|
|
249
|
+
assert main(["drafts", "create", "--account-id", str(aid), "--to", "a@b.com",
|
|
250
|
+
"--subject", "待删草稿", "--body", "x"]) == EXIT_OK
|
|
251
|
+
draft = _out_lines(capsys)[0]["data"]
|
|
252
|
+
assert main(["drafts", "delete", str(draft["id"])]) == EXIT_OK
|
|
253
|
+
assert main(["drafts", "send", str(draft["id"])]) == EXIT_PERMANENT
|
|
254
|
+
|
|
255
|
+
# 在途审批草稿不开放删除 → exit 2 业务拒绝(防 agent 误清审批队列)
|
|
256
|
+
conn = database.get_conn()
|
|
257
|
+
cur = conn.execute(
|
|
258
|
+
"INSERT INTO user_drafts (account_id, mode, status, to_addrs, subject, body_html)"
|
|
259
|
+
" VALUES (?, 'ai', 'pending_review', 'x@y.com', '审批中', '<p>z</p>')", (aid,))
|
|
260
|
+
conn.commit()
|
|
261
|
+
assert main(["drafts", "delete", str(int(cur.lastrowid))]) == EXIT_BAD_PARAMS
|
|
262
|
+
conn.execute("DELETE FROM user_drafts WHERE id = ?", (int(cur.lastrowid),))
|
|
263
|
+
conn.commit()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_folders_sync_wait模式(capsys):
|
|
267
|
+
"v0.4.1:folders sync 带 wait=true 同步执行;无 IMAP 凭据的种子账号按业务失败透传。"
|
|
268
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
269
|
+
save_config("http://127.0.0.1:8720", _make_key(["write"]))
|
|
270
|
+
aid = _seed_account()
|
|
271
|
+
_seed_folder(aid)
|
|
272
|
+
assert main(["folders", "sync", "--account-id", str(aid), "--folder", "INBOX"]) == EXIT_OK
|
|
273
|
+
envelope = _out_lines(capsys)[0]
|
|
274
|
+
# HTTP 成功(CLI envelope ok),data 里是同步结果;种子账号缺凭据 → ok=false 如实透出
|
|
275
|
+
assert envelope["ok"] is True and envelope["data"]["ok"] is False
|
|
276
|
+
assert "started" not in envelope["data"] # wait 模式:不是后台线程的 {started} 响应
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_watch_max_emails自动退出(capsys, monkeypatch):
|
|
280
|
+
"v0.4.1:--max-emails 收满自动退出(agent 调用防挂起),不再必须 Ctrl-C。"
|
|
281
|
+
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
282
|
+
save_config("http://127.0.0.1:8720", _make_key(["read"]))
|
|
283
|
+
aid = _seed_account()
|
|
284
|
+
_seed_email(aid, 1, "历史邮件")
|
|
285
|
+
|
|
286
|
+
# 首轮轮询为空,sleep 时注入一封新邮件 → 下轮输出 1 封即达 --max-emails 退出。
|
|
287
|
+
# 注:cli.time 是全局 time 模块(anyio/限流器也在用 monotonic),不要 mock 它。
|
|
288
|
+
def _seed_and_return(*_a):
|
|
289
|
+
_seed_email(aid, 2, "新邮件")
|
|
290
|
+
monkeypatch.setattr(cli.time, "sleep", _seed_and_return)
|
|
291
|
+
assert main(["watch", "--account-id", str(aid), "--interval", "0",
|
|
292
|
+
"--max-emails", "1"]) == EXIT_OK
|
|
293
|
+
lines = _out_lines(capsys)
|
|
294
|
+
assert len(lines) == 1 and lines[0]["data"]["subject"] == "新邮件"
|
|
295
|
+
|
|
296
|
+
|
|
242
297
|
def test_agent_ask_未配置AI(capsys):
|
|
243
298
|
"B3:AI 未配置时 agent ask 返回明确错误(exit 2,agent 据此告知用户)。"
|
|
244
299
|
client.post("/api/extkeys/enabled", json={"enabled": True})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|