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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nmail-cli
3
- Version: 0.1.0
3
+ Version: 0.4.1
4
4
  Summary: Nmail 对外 API 命令行客户端——为 agent/skill 设计(JSON envelope + exit code 契约 + 两阶段确认)
5
5
  Author: Nmail
6
6
  License: MIT
@@ -0,0 +1,3 @@
1
+ """nmail-cli:Nmail 对外 API 命令行客户端(AGENT_SKILL_PLAN P2,REDESIGN_PLAN §19)。"""
2
+
3
+ __version__ = "0.4.1" # 与 app 同一条版本线:勿手改,python3 scripts/sync_version.py 统一维护
@@ -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
- _stderr(f"watching(base={params['since_id']},Ctrl-C 停止)…")
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
- time.sleep(args.interval)
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 每行一封,Ctrl-C 停止)")
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.0"
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})
@@ -1,3 +0,0 @@
1
- """nmail-cli:Nmail 对外 API 命令行客户端(AGENT_SKILL_PLAN P2,REDESIGN_PLAN §19)。"""
2
-
3
- __version__ = "0.1.0"
File without changes
File without changes
File without changes