rootr-cli 0.24.0 → 0.25.0

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.
package/README.ko.md CHANGED
@@ -307,20 +307,30 @@ rootr sign ledger # 워크스페이스 원장 전체 재계산
307
307
 
308
308
  ### 문서 보내기 · 문서함 (send · mail)
309
309
 
310
- 폴더만 빼고 어떤 노드든 이메일 1~20명에게 **보낼 때의 복사본**으로 보낼 수 있습니다. 담기는 내용은
311
- 공개 페이지와 같아서 사람·첨부·관계 칸은 나가지 않습니다. 받는 사람은 메일 링크로 **루터 계정 없이**
312
- 열고, 그 이메일에 루터 계정이 있거나 나중에 가입하면 받은 문서함에도 그대로 있습니다. 보낸 뒤 원본을
313
- 고치거나 지워도 받은 문서는 그대로입니다. 보내려면 그 노드에 쓰기(WRITE) 권한이 있어야 합니다(공유와 같은 문턱).
310
+ 어떤 노드든(폴더 포함) 이메일 1~20명에게 보낼 수 있습니다. **복사본이 아니라 원본 그 문서**를 그 이메일에
311
+ 열어 주는 것입니다 — `--access read`(보기, 기본) · `comment`(댓글) · `write`(편집). 받는 사람은 언제나 최신
312
+ 내용을 보고, 편집을 받았으면 원본을 직접 고칩니다. 그 이메일(인증된)의 루터 계정은 우리 워크스페이스 구성원이
313
+ 아니어도 문서함에서 그 문서만 엽니다(트리·구성원·검색은 안 보임). 계정이 없으면 메일 링크에서 받은 이메일을
314
+ 넣어야 읽기 전용으로 보입니다. 보내려면 그 노드에 구성원으로서 쓰기(WRITE) 권한이 있어야 하고(공유와 같은
315
+ 문턱), 내 권한까지만 줄 수 있습니다.
314
316
 
315
317
  ```bash
316
- rootr send "/보고서/3분기 실적" --to partner@example.com,cfo@example.com --message "말씀드린 자료입니다"
318
+ rootr send "/보고서/3분기 실적" --to partner@example.com,cfo@example.com --access comment --message "말씀드린 자료입니다"
317
319
  rootr mail # 받은 문서 (안 읽은 수 함께)
318
- rootr mail sent # 보낸 문서 — 읽었는지, 메일 실패
319
- rootr mail show <id> # 열기 (문서는 마크다운 본문, 그 밖은 --json 으로 전부)
320
- rootr mail revoke <id> # 회수 — 받는 쪽에서 본문이 안 보이고 링크는 410
320
+ rootr mail sent # 보낸 문서 — 권한·읽었는지·메일 실패
321
+ rootr mail show <id> # 지금 내용 (문서는 마크다운 본문, 그 밖은 --json 으로 전부, 폴더 안은 --node <아래 id>)
322
+ rootr mail access <id> write # 받는 사람 권한 바꾸기
323
+ rootr mail revoke <id> # 회수 — 받는 사람이 바로 못 열고 링크는 410
321
324
  rootr mail hide <id> # 내 문서함에서만 지우기
325
+ rootr mail webhook add https://example.com/hooks/rootr # 내 계정 웹훅: doc_mail.received / revoked / read
326
+ rootr mail webhook list | rm <id> | test <id>
322
327
  ```
323
328
 
329
+ 다른 명령을 쓰고 나면 새로 받은 문서가 있을 때 stderr 로 한 줄이 나옵니다(`📬 받은 문서 2건 — … · rootr mail`).
330
+ stdout 과 `--json` 출력은 그대로입니다. 끄려면 `ROOTR_MAIL_NOTICE=0`. MCP 서버는 같은 소식을 아무 도구 응답 끝에
331
+ 키마다 한 번 붙입니다. 웹훅 요청은 서명되어 있습니다 — `add` 때 한 번 보이는 비밀값으로
332
+ `X-Rootr-Signature: sha256=HMAC_SHA256(secret, 원문)` 을 확인하세요. 몸통에는 문서 내용이 없습니다(`apiUrl` 을 부르세요).
333
+
324
334
  ### 제보 보내기 (feedback)
325
335
 
326
336
  ```bash
@@ -368,7 +378,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
368
378
 
369
379
  ## MCP 서버로 사용하기
370
380
 
371
- `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 197종:
381
+ `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 202종:
372
382
 
373
383
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
374
384
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -651,7 +661,7 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
651
661
 
652
662
  | 도구 | 설명 |
653
663
  |---|---|
654
- | `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱, 하위까지 |
664
+ | `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱·페이지, 하위까지. **페이지(PAGE)는 Team 플랜부터**(무료는 403 `PLAN_UPGRADE_REQUIRED`, 하위 포함 시 페이지는 건너뛰고 개수를 알려 줌) |
655
665
  | `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료) |
656
666
  | `rootr_node_public_status` | 지금 공개돼 있는지, 주소는 무엇인지 |
657
667
  | `rootr_publish_document` | 문서 전용 발행(구형 — 새로 짤 때는 `rootr_publish_node`) |
@@ -819,10 +829,15 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
819
829
 
820
830
  | 도구 | 설명 |
821
831
  |---|---|
822
- | `rootr_send_document` | 노드 하나를 이메일 1~20명에게 보낼 때의 복사본으로 보내기 (계정 없어도 메일 링크로 열림) |
823
- | `rootr_list_doc_mail` | 내 문서함 — `box`: inbox(받은, 기본) / sent(보낸), 안 읽은 수 함께 |
824
- | `rootr_read_doc_mail` | 한 통 열기 — 본문(`payload`), 받은 것은 읽음 처리, 회수된 것은 본문 없음 |
825
- | `rootr_revoke_doc_mail` | 보낸 문서 회수 (보낸 사람만) |
832
+ | `rootr_send_document` | 노드 하나를 이메일 1~20명에게 보내기 — 복사본이 아니라 원본을 `access`(read 보기·comment 댓글·write 편집)만큼 열어 줌 |
833
+ | `rootr_list_doc_mail` | 내 문서함 — `box`: inbox(받은, 기본) / sent(보낸), 안 읽은 수·권한·원본 상태 함께 |
834
+ | `rootr_read_doc_mail` | 한 통 열기 — 원본의 지금 내용(읽기 전용)·권한·원본 주소, 폴더면 `node` 로 아래 것, 받은 것은 읽음 처리 |
835
+ | `rootr_set_doc_mail_access` | 보낸 문서의 받는 사람 권한 바꾸기 (보낸 사람만, 내 권한까지) |
836
+ | `rootr_revoke_doc_mail` | 보낸 문서 회수 — 받는 사람이 바로 못 연다 (보낸 사람만) |
837
+ | `rootr_add_doc_mail_webhook` | 내 계정 받은 문서 웹훅 만들기 — doc_mail.received·revoked·read, HMAC 서명, 비밀값은 한 번만 |
838
+ | `rootr_list_doc_mail_webhooks` | 내 받은 문서 웹훅 목록(마지막 전달 상태) |
839
+ | `rootr_remove_doc_mail_webhook` | 내 받은 문서 웹훅 지우기 |
840
+ | `rootr_test_doc_mail_webhook` | 시험 전송(doc_mail.test) 후 전달 결과 |
826
841
 
827
842
  문서함은 워크스페이스가 아니라 **사람(이메일)**에 붙습니다. 사람 없는 워크스페이스 키(원격 커넥터)는
828
843
  그 키를 만든 구성원으로 동작합니다.
@@ -899,6 +914,10 @@ DATABASE·SPREADSHEET·LOG 노드이고, 칸은 **이름**으로 가리킵니다
899
914
  페이지에서 조용히 빠집니다(일부러 그렇습니다).
900
915
 
901
916
  공개는 여느 노드와 같습니다: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
917
+ 단 **페이지를 웹에 게시하는 것은 Team 플랜부터**입니다 — 무료 워크스페이스는 403 `PLAN_UPGRADE_REQUIRED`
918
+ (아무것도 바뀌지 않음). 무료에서도 페이지를 만들고 편집하고 워크스페이스 안에서 보는 것은 그대로 됩니다.
919
+ Team 에서 공개한 뒤 무료로 내려가면 공개 설정은 남아 있지만 방문자에게는 "게시되어 있지 않습니다" 화면이
920
+ 나가고, 다시 Team 으로 올리면 같은 주소가 다시 발행하지 않아도 열립니다.
902
921
 
903
922
  #### 프레젠테이션
904
923
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
11
11
  *(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
12
12
 
13
13
  - `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
14
- - `rootr mcp` — stdio MCP server, **197 tools** (see [MCP tools](#mcp-tools))
14
+ - `rootr mcp` — stdio MCP server, **202 tools** (see [MCP tools](#mcp-tools))
15
15
 
16
16
  Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
17
17
 
@@ -269,7 +269,15 @@ rootr publish /handbook --status # is it public, at what URL, chi
269
269
  rootr publish /projects/tasks --layout board --skin paper # 9 layouts × 6 skins
270
270
  rootr publish /handbook/policy.md --comments --expires 2026-12-31T00:00:00Z
271
271
  rootr publish /handbook --off
272
+ ```
273
+
274
+ Every node type publishes on the Free plan **except PAGE** (the block-built web page): publishing a
275
+ page to the web needs the **Team plan**. On Free, `rootr publish <page>` fails with
276
+ `PLAN_UPGRADE_REQUIRED` and nothing changes; `--children` skips pages underneath and says how many.
277
+ A page published on Team stays saved if the workspace later drops to Free — visitors see a
278
+ "not published" screen until it is upgraded again, and `--status` says so.
272
279
 
280
+ ```bash
273
281
  rootr access /handbook --private # or --public / --inherit
274
282
  rootr access /handbook --grant <userId> --level WRITE
275
283
  rootr access /handbook --revoke <userId>
@@ -329,21 +337,32 @@ regulations require both the original and the trace of the change to remain visi
329
337
 
330
338
  ### Sending a document to someone (mailbox)
331
339
 
332
- Any node except a folder can be emailed to 1–20 addresses as a **copy taken at send time** — the
333
- same content a public page shows, so person / attachment / relation columns never go out. The
334
- recipient opens it from the email link **without a Rootr account**; if that address has (or later
335
- gets) a Rootr account, it is also in their inbox. Editing or deleting the original afterwards does
336
- not change what they received. Sending needs WRITE on the node, the same bar as sharing.
340
+ Any node — a folder too — can be sent to 1–20 email addresses. It is **not a copy**: the address
341
+ gets access to the **original** at `--access read` (view, default) · `comment` · `write` (edit), so
342
+ they always see the latest version and, with `write`, edit the original itself. A Rootr account on
343
+ that (verified) email opens it from its inbox even if it is not in your workspace — it sees that
344
+ node only, never your tree, members or search. Without an account the email link asks for the
345
+ address and then shows it read-only. Sending needs WRITE on the node as a member (the same bar as
346
+ sharing); you can give at most your own level.
337
347
 
338
348
  ```bash
339
- rootr send "/reports/Q3 numbers" --to partner@example.com,cfo@example.com --message "As discussed"
349
+ rootr send "/reports/Q3 numbers" --to partner@example.com,cfo@example.com --access comment --message "As discussed"
340
350
  rootr mail # received (with the unread count)
341
- rootr mail sent # what you sent — read or not, email failures
342
- rootr mail show <id> # open it (a document prints its markdown; --json for any type)
343
- rootr mail revoke <id> # withdraw: the recipient no longer sees the content, the link answers 410
351
+ rootr mail sent # what you sent — access, read or not, email failures
352
+ rootr mail show <id> # the current content (a document prints its markdown; --json for any type; --node <child> inside a folder)
353
+ rootr mail access <id> write # change what the recipient can do
354
+ rootr mail revoke <id> # withdraw: the recipient loses access at once, the link answers 410
344
355
  rootr mail hide <id> # remove from your own mailbox only
356
+ rootr mail webhook add https://example.com/hooks/rootr # your own webhook: doc_mail.received / revoked / read
357
+ rootr mail webhook list | rm <id> | test <id>
345
358
  ```
346
359
 
360
+ After any other command, newly received documents print one line to stderr
361
+ (`📬 2 new documents received — … · rootr mail`); stdout and `--json` stay clean. Turn it off with
362
+ `ROOTR_MAIL_NOTICE=0`. The MCP server appends the same news to the end of any tool result, once
363
+ per key. Webhook requests are signed: verify `X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body)`
364
+ with the secret shown once at `add`. The body never contains the document — GET its `apiUrl`.
365
+
347
366
  ### Team memory
348
367
 
349
368
  ```bash
@@ -425,7 +444,7 @@ is refused and says when the grace was used.
425
444
 
426
445
  ## MCP tools
427
446
 
428
- `rootr mcp` speaks MCP over stdio. 197 tools:
447
+ `rootr mcp` speaks MCP over stdio. 202 tools:
429
448
 
430
449
  | Group | Count | Tools |
431
450
  |---|---|---|
@@ -543,7 +562,8 @@ takes `width: half | third | twoThirds` so a page becomes a **live dashboard**
543
562
  published separately. Tables and forms that live under a PRIVATE subtree are silently dropped
544
563
  from a public page, on purpose.
545
564
 
546
- Publishing works like any other node: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
565
+ Publishing works like any other node: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}` —
566
+ **Team plan and up** for pages (Free → `403 PLAN_UPGRADE_REQUIRED`; pages still work inside the workspace).
547
567
 
548
568
  ### Embedding a table inside a document
549
569
 
package/bin/rootr.js CHANGED
@@ -6,6 +6,7 @@ import { dirname, join, basename, extname } from 'node:path';
6
6
  import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
7
7
  import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
8
8
  import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
9
+ import { setGlobalDocMailSink } from '../lib/context.js';
9
10
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
10
11
  import { t as tr, lang } from '../lib/i18n.js';
11
12
 
@@ -95,11 +96,16 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
95
96
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
96
97
  rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 올리기+서명 (코드 하나로, 초안 포함)
97
98
  rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
98
- rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."] 문서(어떤 타입이든)를 이메일로 보내기 — 보낼 때의 복사본, 계정 없어도 열림
99
+ rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
100
+ 문서(어떤 타입이든)를 이메일로 보내기 — 복사본이 아니라 원본을 그 이메일에 열어 줌(기본 보기)
99
101
  rootr mail [inbox|sent] [--limit N] [--q <찾기>] [--json] 문서함 — 받은 문서(기본)·보낸 문서
100
- rootr mail show <id> [--json] 받은/보낸 문서 열기(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
101
- rootr mail revoke <id> 보낸 문서 회수 — 받는 쪽에서 본문이 안 보임
102
- rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(상대 화면은 그대로)
102
+ rootr mail show <id> [--node <아래 노드 id>] [--json] 받은/보낸 문서의 지금 내용(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
103
+ rootr mail access <id> read|comment|write 보낸 문서의 받는 사람 권한 바꾸기
104
+ rootr mail revoke <id> 보낸 문서 회수 — 받는 사람이 원본을 더 못 연다
105
+ rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(받은 것을 지우면 그 계정으로는 더 안 열린다)
106
+ rootr mail webhook add <url> [--events …] | list | rm <id> | test <id>
107
+ 받은 문서·회수·읽음을 내 주소로 알려 주는 웹훅(내 계정 것, HMAC 서명)
108
+ ※ 다른 명령 뒤 새로 받은 문서가 있으면 stderr 로 한 줄 알림(끄기: ROOTR_MAIL_NOTICE=0)
103
109
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
104
110
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
105
111
  rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
@@ -206,11 +212,13 @@ usage:
206
212
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] list signed documents (--pending: waiting on you)
207
213
  rootr sign bulk <id…> --code <6 digits> submit+sign many at once (one code, drafts included)
208
214
  rootr sign ledger [--json] verify the whole workspace ledger (hash chain)
209
- rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."] email any node as a frozen copy — opens without an account
215
+ rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
216
+ give email addresses access to any node — the original itself, not a copy (default: read)
210
217
  rootr mail [inbox|sent] [--limit N] [--q <text>] [--json] your document mailbox — received (default) or sent
211
- rootr mail show <id> [--json] open a received/sent document (body for docs, a summary otherwise); marks received ones read
212
- rootr mail revoke <id> withdraw a document you sent — the recipient no longer sees the content
213
- rootr mail hide <id> [--box inbox|sent] remove from your own mailbox only (the other side keeps it)
218
+ rootr mail show <id> [--node <child id>] [--json] the document's current content (body for docs, a summary otherwise); marks received ones read
219
+ rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
220
+ rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
221
+ rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
214
222
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
215
223
  rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
216
224
  rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
@@ -1749,6 +1757,18 @@ async function cmdPublish(args) {
1749
1757
  console.log(tr('이 종류는 공개할 수 없습니다(폴더).', `This node type cannot be published (folder).`));
1750
1758
  return;
1751
1759
  }
1760
+ if (st.closed === 'PAGE_PUBLISH_REQUIRES_TEAM') {
1761
+ // PAGE-PUBLISH-TEAM — 공개는 켜져 있지만 워크스페이스가 무료라 방문자에게 안 보인다. Team 으로 올리면 같은 주소가 다시 열린다.
1762
+ console.log(tr(`공개가 켜져 있지만 지금은 방문자에게 보이지 않습니다(주소: /p/${st.slug}).`, `Publishing is on, but visitors cannot see it right now (address: /p/${st.slug}).`));
1763
+ console.log(
1764
+ ' ' +
1765
+ tr(
1766
+ '페이지를 웹에 게시하는 기능은 Team 플랜에서만 됩니다. Team 으로 올리면 다시 발행하지 않아도 같은 주소로 열립니다.',
1767
+ 'Publishing pages to the web needs the Team plan. Upgrade to Team and the same address opens again — no need to publish again.',
1768
+ ),
1769
+ );
1770
+ return;
1771
+ }
1752
1772
  if (st.closed) {
1753
1773
  // 공개를 끈 것과 다르다 — 설정은 켜져 있고, "본인이 만든 행만" 표의 행에서 나온 것이라 지금 닫혀 있다(서버 응답의 closed)
1754
1774
  console.log(tr(`공개가 켜져 있지만 지금은 닫혀 있습니다(주소: /p/${st.slug}).`, `Publishing is on, but it is closed right now (address: /p/${st.slug}).`));
@@ -1833,6 +1853,15 @@ async function cmdPublish(args) {
1833
1853
  });
1834
1854
  console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
1835
1855
  if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
1856
+ if (res.skippedPagesRequireTeam) {
1857
+ // PAGE-PUBLISH-TEAM — 무료 워크스페이스에서는 아래의 페이지(PAGE)는 따라 나가지 않는다.
1858
+ console.log(
1859
+ tr(
1860
+ ` 단, 아래의 페이지 ${res.skippedPagesRequireTeam}개는 공개하지 않았습니다 — 페이지를 웹에 게시하는 기능은 Team 플랜부터입니다.`,
1861
+ ` ${res.skippedPagesRequireTeam} page(s) underneath were NOT published — publishing pages to the web needs the Team plan.`,
1862
+ ),
1863
+ );
1864
+ }
1836
1865
  if (Object.keys(design).length) {
1837
1866
  console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
1838
1867
  }
@@ -3074,7 +3103,7 @@ async function cmdSign(args) {
3074
3103
  // ---- doc mail (DOC-MAIL — 문서 송수신) ------------------------------------
3075
3104
 
3076
3105
  const MAIL_USAGE =
3077
- usage(`rootr mail [inbox|sent] | show <id> | revoke <id> | hide <id>\n`) +
3106
+ usage(`rootr mail [inbox|sent] | show <id> | access <id> <read|comment|write> | revoke <id> | hide <id> | webhook …\n`) +
3078
3107
  tr(' (자세한 건 rootr --help)', ` (see rootr --help)`);
3079
3108
 
3080
3109
  const NODE_TYPE_KO = {
@@ -3092,23 +3121,34 @@ const NODE_TYPE_KO = {
3092
3121
  SIGNED: tr('서명문서', 'signed document'),
3093
3122
  };
3094
3123
 
3124
+ const ACCESS_LABEL = {
3125
+ READ: tr('보기', 'view'),
3126
+ COMMENT: tr('댓글', 'comment'),
3127
+ WRITE: tr('편집', 'edit'),
3128
+ };
3129
+
3095
3130
  async function cmdSend(args) {
3096
3131
  const target = args._[0];
3097
3132
  const toArg = typeof args.to === 'string' ? args.to : '';
3098
3133
  const to = toArg.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
3099
- if (!target || !to.length) {
3100
- throw new Error(usage(tr(`rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]`, `rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]`)));
3134
+ const SEND_USAGE = `rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]`;
3135
+ if (!target || !to.length) throw new Error(usage(SEND_USAGE));
3136
+ const access = typeof args.access === 'string' ? args.access.trim().toLowerCase() : undefined;
3137
+ if (access && !['read', 'comment', 'write', 'view', 'edit'].includes(access)) {
3138
+ throw new Error(tr(`--access 는 read(보기)·comment(댓글)·write(편집) 중 하나입니다.`, `--access must be read, comment or write.`));
3101
3139
  }
3102
3140
  const client = makeClient();
3103
3141
  const nodeId = requireResolved(await resolveTarget(client, target));
3104
- const out = await client.sendNode(nodeId, { to, message: typeof args.message === 'string' ? args.message : undefined });
3142
+ const out = await client.sendNode(nodeId, { to, access, message: typeof args.message === 'string' ? args.message : undefined });
3105
3143
  if (args.json) return console.log(JSON.stringify(out, null, 2));
3106
3144
  const items = out.items ?? [];
3107
- console.log(tr(`${items.length}명에게 보냈습니다 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} (batch ${out.batchId})`));
3145
+ const lvl = ACCESS_LABEL[out.access] ?? out.access;
3146
+ console.log(tr(`${items.length}명에게 보냈습니다 — 원본을 ${lvl} 권한으로 열어 줌 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} — they can ${lvl} the original (batch ${out.batchId})`));
3108
3147
  for (const it of items) {
3109
3148
  console.log(
3110
3149
  ` ${it.to}\t${it.id}\t` +
3111
- (it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 문서는 받은 문서함에 있습니다`, `email failed${it.error ? `: ${it.error}` : ''} — the copy is still in their inbox`)),
3150
+ (it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 권한은 그대로 있습니다(받은 문서함)`, `email failed${it.error ? `: ${it.error}` : ''} — the access still stands (their inbox)`)) +
3151
+ (it.resent ? tr(' (다시 보냄 — 권한·메시지만 바꿈)', ' (re-sent — access/message updated)') : ''),
3112
3152
  );
3113
3153
  }
3114
3154
  }
@@ -3132,34 +3172,41 @@ async function cmdMail(args) {
3132
3172
  const type = NODE_TYPE_KO[m.nodeType] ?? m.nodeType;
3133
3173
  const state = m.revoked
3134
3174
  ? tr('[회수됨]', '[withdrawn]')
3175
+ : m.originalDeleted
3176
+ ? tr('[원본 삭제됨]', '[original deleted]')
3135
3177
  : box === 'inbox'
3136
3178
  ? m.readAt ? '' : tr('[새 문서]', '[new]')
3137
3179
  : m.readAt ? tr(`[읽음 ${mailWhen(m.readAt)}]`, `[read ${mailWhen(m.readAt)}]`) : tr('[안 읽음]', '[unread]');
3138
3180
  const who = box === 'inbox' ? `${m.senderName} <${m.senderEmail}>` : `→ ${m.recipientEmail}`;
3139
3181
  const mailFail = box === 'sent' && m.emailed === false ? tr(' [메일 실패]', ' [email failed]') : '';
3140
- console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
3182
+ const lvl = ACCESS_LABEL[m.access] ? ` (${ACCESS_LABEL[m.access]})` : '';
3183
+ console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}${lvl}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
3141
3184
  if (m.message) console.log(` ${String(m.message).split('\n')[0].slice(0, 100)}`);
3142
3185
  }
3143
3186
  if (out.nextCursor) console.log(tr(`\n다음 쪽: rootr mail ${box} --cursor ${out.nextCursor}`, `\nNext page: rootr mail ${box} --cursor ${out.nextCursor}`));
3144
3187
  return;
3145
3188
  }
3189
+ if (sub === 'webhook' || sub === 'webhooks') return cmdMailWebhook(client, args);
3146
3190
  const id = args._[1];
3147
3191
  if (!id) throw new Error(MAIL_USAGE);
3148
3192
  switch (sub) {
3149
3193
  case 'show':
3150
3194
  case 'read': {
3151
- const m = await client.getDocMail(id);
3195
+ const m = await client.getDocMail(id, { node: typeof args.node === 'string' ? args.node : undefined });
3152
3196
  if (args.json) return console.log(JSON.stringify(m, null, 2));
3153
- console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType})`);
3197
+ console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType} · ${tr('권한', 'access')} ${ACCESS_LABEL[m.access] ?? m.access})`);
3154
3198
  console.log(tr(`보낸 사람: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`, `From: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`));
3155
3199
  console.log(tr(`받는 사람: ${m.recipientEmail} · 보낸 때 ${mailWhen(m.createdAt)}${m.readAt ? ` · 읽음 ${mailWhen(m.readAt)}` : ''}`, `To: ${m.recipientEmail} · sent ${mailWhen(m.createdAt)}${m.readAt ? ` · read ${mailWhen(m.readAt)}` : ''}`));
3156
3200
  if (m.message) console.log(tr(`메시지: ${m.message}`, `Message: ${m.message}`));
3157
- if (m.revoked && !m.payload) return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
3158
- if (m.revoked) console.log(tr('(회수함 — 받는 쪽에서는 본문이 안 보입니다)', '(withdrawn — the recipient no longer sees the content)'));
3201
+ if (m.revoked && m.viewer !== 'sender') return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
3202
+ if (m.originalDeleted) return console.log(tr('\n원본이 삭제되었습니다.', '\nThe original has been deleted.'));
3203
+ if (m.revoked) console.log(tr('(회수함 — 받는 사람은 더 못 엽니다)', '(withdrawn — the recipient can no longer open it)'));
3204
+ if (m.openUrl) console.log(tr(`원본 열기(편집기): ${m.openUrl}`, `Open the original (editor): ${m.openUrl}`));
3205
+ if (m.picked) console.log(tr(`보고 있는 것: ${m.picked.name} (${m.picked.id})`, `Showing: ${m.picked.name} (${m.picked.id})`));
3159
3206
  const p = m.payload ?? {};
3160
3207
  console.log('');
3161
- if (p.kind === 'doc') return console.log(p.content ?? '');
3162
- if (p.kind === 'database') {
3208
+ if (typeof m.contentMd === 'string' || p.kind === 'doc') console.log(m.contentMd ?? p.content ?? '');
3209
+ else if (p.kind === 'database') {
3163
3210
  const props = p.properties ?? [];
3164
3211
  const rows = p.rows ?? [];
3165
3212
  console.log(props.map((c) => c.name).join('\t'));
@@ -3169,30 +3216,107 @@ async function cmdMail(args) {
3169
3216
  return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
3170
3217
  }).join('\t'));
3171
3218
  }
3172
- if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 담겼습니다`, `… ${p.truncated.shown} of ${p.truncated.total} rows were included`));
3173
- return;
3219
+ if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 보입니다`, `… showing ${p.truncated.shown} of ${p.truncated.total} rows`));
3220
+ if (p.rowsHidden === 'booking') console.log(tr('(예약을 받는 표라 행은 안 보입니다)', '(a booking table — rows are not shown)'));
3221
+ } else if (p.kind) {
3222
+ const keys = Object.keys(p).filter((k) => k !== 'kind');
3223
+ console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
3224
+ }
3225
+ if (m.children?.length) {
3226
+ console.log(tr('\n안에 든 것 (rootr mail show <id> --node <아래 id>):', '\nInside (rootr mail show <id> --node <child id>):'));
3227
+ for (const c of m.children) console.log(` ${c.id}\t${NODE_TYPE_KO[c.type] ?? c.type}\t${c.name}`);
3174
3228
  }
3175
- const keys = Object.keys(p).filter((k) => k !== 'kind');
3176
- console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
3177
3229
  return;
3178
3230
  }
3231
+ case 'access': {
3232
+ const level = args._[2];
3233
+ if (!level || !['read', 'comment', 'write', 'view', 'edit'].includes(String(level).toLowerCase())) throw new Error(MAIL_USAGE);
3234
+ const out = await client.setDocMailAccess(id, String(level).toLowerCase());
3235
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3236
+ return console.log(tr(`받는 사람 권한을 ${ACCESS_LABEL[out.access] ?? out.access}(으)로 바꿨습니다.`, `The recipient can now ${ACCESS_LABEL[out.access] ?? out.access}.`));
3237
+ }
3179
3238
  case 'revoke': {
3180
3239
  const out = await client.revokeDocMail(id);
3181
3240
  if (args.json) return console.log(JSON.stringify(out, null, 2));
3182
- return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 쪽에서 본문이 안 보입니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient no longer sees the content.`));
3241
+ return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 사람이 원본을 더 못 엽니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient can no longer open the original.`));
3183
3242
  }
3184
3243
  case 'hide':
3185
3244
  case 'rm': {
3186
3245
  const box = args.box === 'inbox' || args.box === 'sent' ? args.box : undefined;
3187
3246
  const out = await client.hideDocMail(id, { box });
3188
3247
  if (args.json) return console.log(JSON.stringify(out, null, 2));
3189
- return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다.`, `Removed from your mailbox (${out.hidden}). The other side still has it.`));
3248
+ return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다(받은 것을 지웠으면 내 계정으로는 더 안 열립니다).`, `Removed from your mailbox (${out.hidden}). The other side still has it (a removed received document no longer opens for your account).`));
3190
3249
  }
3191
3250
  default:
3192
3251
  throw new Error(MAIL_USAGE);
3193
3252
  }
3194
3253
  }
3195
3254
 
3255
+ const WEBHOOK_USAGE = usage(
3256
+ tr(
3257
+ `rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <이름>]\n` +
3258
+ ` rootr mail webhook list | rm <id> | test <id>`,
3259
+ `rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <name>]\n` +
3260
+ ` rootr mail webhook list | rm <id> | test <id>`,
3261
+ ),
3262
+ );
3263
+
3264
+ /** DOC-MAIL-HOOK — 내(계정) 받은 문서 웹훅. 워크스페이스 웹훅과 따로 — 받은 문서는 사람의 것이다. */
3265
+ async function cmdMailWebhook(client, args) {
3266
+ const op = args._[1];
3267
+ const arg = args._[2];
3268
+ switch (op) {
3269
+ case 'add': {
3270
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3271
+ const events = typeof args.events === 'string' ? args.events.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
3272
+ const out = await client.addDocMailWebhook({ url: arg, events, name: typeof args.name === 'string' ? args.name : undefined });
3273
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3274
+ console.log(tr(`웹훅을 만들었습니다: ${out.id}`, `Webhook created: ${out.id}`));
3275
+ console.log(` url: ${out.url}`);
3276
+ console.log(` events: ${out.events.join(', ')}`);
3277
+ console.log(tr(` 비밀값(지금 한 번만 보입니다 — 서명 X-Rootr-Signature 검증용): ${out.secret}`, ` secret (shown once — verify X-Rootr-Signature with it): ${out.secret}`));
3278
+ return;
3279
+ }
3280
+ case 'list':
3281
+ case 'ls':
3282
+ case undefined: {
3283
+ const out = await client.listDocMailWebhooks();
3284
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3285
+ if (!out.items?.length) return console.log(tr('웹훅이 없습니다. rootr mail webhook add <url>', 'No webhooks. rootr mail webhook add <url>'));
3286
+ for (const h of out.items) {
3287
+ const last = h.lastDelivery ? ` · ${h.lastDelivery.event} ${h.lastDelivery.status}${h.lastDelivery.responseCode ? ` ${h.lastDelivery.responseCode}` : ''}` : '';
3288
+ console.log(`${h.id}\t${h.active ? 'on' : 'off'}\t${h.url}\t${h.events.join(',')}${last}`);
3289
+ }
3290
+ return;
3291
+ }
3292
+ case 'rm':
3293
+ case 'remove':
3294
+ case 'delete': {
3295
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3296
+ const out = await client.removeDocMailWebhook(arg);
3297
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3298
+ return console.log(tr(`웹훅을 지웠습니다: ${arg}`, `Webhook removed: ${arg}`));
3299
+ }
3300
+ case 'test': {
3301
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3302
+ const queued = await client.testDocMailWebhook(arg);
3303
+ let d = null;
3304
+ for (let i = 0; i < 10; i++) {
3305
+ await new Promise((r) => setTimeout(r, 750));
3306
+ d = (await client.docMailWebhookDeliveries(arg)).items?.find((x) => x.id === queued.deliveryId) ?? null;
3307
+ if (d && d.status !== 'PENDING') break;
3308
+ }
3309
+ if (args.json) return console.log(JSON.stringify({ ...queued, delivery: d }, null, 2));
3310
+ if (!d || d.status === 'PENDING') return console.log(tr(`시험 전송을 큐에 넣었습니다(${queued.deliveryId}) — 아직 결과가 없습니다.`, `Test queued (${queued.deliveryId}) — no result yet.`));
3311
+ if (d.status === 'SUCCESS') return console.log(tr(`시험 전송 성공 (HTTP ${d.responseCode})`, `Test delivered (HTTP ${d.responseCode})`));
3312
+ process.exitCode = 1;
3313
+ return console.log(tr(`시험 전송 실패: ${d.error ?? d.responseCode} — 다시 시도합니다(최대 5번).`, `Test failed: ${d.error ?? d.responseCode} — it will be retried (up to 5 times).`));
3314
+ }
3315
+ default:
3316
+ throw new Error(WEBHOOK_USAGE);
3317
+ }
3318
+ }
3319
+
3196
3320
  async function main() {
3197
3321
  const argv = process.argv.slice(2);
3198
3322
  const args = parseArgs(argv);
@@ -3277,4 +3401,31 @@ async function main() {
3277
3401
  }
3278
3402
  }
3279
3403
 
3280
- main().catch(printErrorAndExit);
3404
+ /**
3405
+ * DOC-MAIL-NOTIFY — 아무 명령이든 끝난 뒤, 그 명령이 부른 API 응답 머리줄에 **새로 받은 문서**가 있었으면 stderr 로 한 줄
3406
+ * (stdout·--json 출력은 더럽히지 않는다). 따로 묻는 호출은 없다. 서버가 키마다 한 번만 알려 준다(channel 'cli').
3407
+ * `rootr mail`·`rootr mcp` 에서는 안 낸다(이미 문서함을 보고 있다 / 표준 입출력이 MCP 통로다).
3408
+ * 끄기: `ROOTR_MAIL_NOTICE=0`.
3409
+ */
3410
+ const docMailSink = { channel: 'cli', items: [] };
3411
+ const firstArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
3412
+ const docMailNoticeOn = process.env.ROOTR_MAIL_NOTICE !== '0' && firstArg !== 'mail' && firstArg !== 'mcp';
3413
+ if (docMailNoticeOn) setGlobalDocMailSink(docMailSink);
3414
+ function printDocMailNotice() {
3415
+ if (!docMailNoticeOn || !docMailSink.items.length) return;
3416
+ const n = Math.max(docMailSink.items.length, docMailSink.newCount || 0);
3417
+ const first = docMailSink.items[0];
3418
+ console.error(
3419
+ tr(
3420
+ `📬 받은 문서 ${n}건 — 「${first.title}」 ${first.sender}${n > 1 ? ' 외' : ''} · rootr mail`,
3421
+ `📬 ${n} new document${n > 1 ? 's' : ''} received — "${first.title}" from ${first.sender}${n > 1 ? ' and more' : ''} · rootr mail`,
3422
+ ),
3423
+ );
3424
+ }
3425
+
3426
+ main()
3427
+ .then(printDocMailNotice)
3428
+ .catch((err) => {
3429
+ printDocMailNotice();
3430
+ printErrorAndExit(err);
3431
+ });
package/lib/client.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * can render consistent, helpful messages.
7
7
  */
8
8
 
9
- import { currentRateLimitSink } from './context.js';
9
+ import { currentDocMailSink, currentRateLimitSink } from './context.js';
10
10
  import { t as tr } from './i18n.js';
11
11
 
12
12
  /** Copy the API's X-RateLimit-* headers into the ambient per-call sink, if any. */
@@ -23,6 +23,25 @@ function captureRateLimit(res) {
23
23
  sink.upgradeUrl = res.headers.get('x-ratelimit-upgrade-url') || undefined;
24
24
  }
25
25
 
26
+ /** DOC-MAIL-NOTIFY — 응답의 안 읽은 받은 문서 머리줄을 그릇에 모은다(그릇이 없으면 아무 일도 안 한다). */
27
+ function captureDocMail(res, sink) {
28
+ if (!sink) return;
29
+ const unread = Number(res.headers.get('x-rootr-doc-mail-unread'));
30
+ if (Number.isFinite(unread) && res.headers.get('x-rootr-doc-mail-unread') !== null) sink.unread = unread;
31
+ const raw = res.headers.get('x-rootr-doc-mail-new-items');
32
+ if (!raw) return;
33
+ try {
34
+ const items = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
35
+ sink.items = sink.items || [];
36
+ for (const it of Array.isArray(items) ? items : []) {
37
+ if (it && it.id && !sink.items.some((x) => x.id === it.id)) sink.items.push(it);
38
+ }
39
+ sink.newCount = Math.max(sink.newCount || 0, Number(res.headers.get('x-rootr-doc-mail-new')) || 0);
40
+ } catch {
41
+ /* 알림만 빠진다 */
42
+ }
43
+ }
44
+
26
45
  export class RootrApiError extends Error {
27
46
  constructor(message, { status, body, hint } = {}) {
28
47
  super(message);
@@ -154,6 +173,8 @@ export class RootrClient {
154
173
  ...this.defaultHeaders,
155
174
  ...(opts.headers || {}),
156
175
  };
176
+ const docMailSink = currentDocMailSink();
177
+ if (docMailSink?.channel) headers['X-Rootr-Doc-Mail-Channel'] = docMailSink.channel;
157
178
 
158
179
  let body = opts.body;
159
180
  if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
@@ -173,6 +194,7 @@ export class RootrClient {
173
194
  }
174
195
 
175
196
  captureRateLimit(res);
197
+ if (res.ok) captureDocMail(res, docMailSink);
176
198
 
177
199
  if (opts.returnResponse) {
178
200
  if (!res.ok) {
@@ -2051,15 +2073,16 @@ export class RootrClient {
2051
2073
 
2052
2074
  // ---- Doc mail (DOC-MAIL — 문서 송수신) ---------------------------------
2053
2075
  //
2054
- // 노드 하나를 **보낼 때의 복사본**으로 이메일 1~20명에게 보낸다. 받은 문서는 받는 사람의
2055
- // 이메일에 붙는다 — 계정이 없어도 메일 링크로 열리고, 나중에 그 이메일로 가입하면 문서함에
2056
- // 그대로 있다. 문서함은 사람(이메일)에 붙으므로 워크스페이스 주소 밑이 아니다.
2076
+ // 노드 하나를 이메일 1~20명에게 **보낸다 = 원본을 그 이메일에 열어 준다**(복사본이 아니다 — DOC-MAIL-LIVE).
2077
+ // 받는 사람은 원본을 실시간으로 보고, 받은 권한(read 보기·comment 댓글·write 편집)만큼 쓴다. 계정이 없으면 메일 링크에서
2078
+ // 받은 이메일을 넣어 읽기 전용으로 열고, 그 이메일로 가입하면 문서함에서 원본이 열린다. 문서함은 사람(이메일)에 붙으므로
2079
+ // 워크스페이스 주소 밑이 아니다.
2057
2080
 
2058
- /** 보내기 — WRITE 이상. 응답 `{batchId, items:[{id,to,emailed}]}`. */
2059
- async sendNode(nodeId, { to, message } = {}) {
2081
+ /** 보내기 — 구성원 규칙으로 WRITE 이상. access: read|comment|write(기본 read). 응답 `{batchId, access, items:[{id,to,emailed,resent}]}`. */
2082
+ async sendNode(nodeId, { to, message, access } = {}) {
2060
2083
  return this.request(`/nodes/${encodeURIComponent(nodeId)}/send`, {
2061
2084
  method: 'POST',
2062
- body: { to, ...(message ? { message } : {}) },
2085
+ body: { to, ...(message ? { message } : {}), ...(access ? { access } : {}) },
2063
2086
  });
2064
2087
  }
2065
2088
 
@@ -2074,16 +2097,47 @@ export class RootrClient {
2074
2097
  return this.request(`/doc-mail${s ? `?${s}` : ''}`);
2075
2098
  }
2076
2099
 
2077
- /** 한 통 열기(본문 payload 포함). 받는 사람이 열면 읽음으로 바뀐다. */
2078
- async getDocMail(id) {
2079
- return this.request(`/doc-mail/${encodeURIComponent(id)}`);
2100
+ /**
2101
+ * 한 통 열기 — 원본의 **지금** 내용(읽기 전용: 문서면 contentMd, 그 밖은 payload)·받은 권한(access)·원본 주소(openUrl).
2102
+ * 폴더를 받았으면 children 이 오고, node 로 그 아래 것을 골라 읽는다. 받는 사람이 열면 읽음으로 바뀐다.
2103
+ */
2104
+ async getDocMail(id, { node } = {}) {
2105
+ return this.request(`/doc-mail/${encodeURIComponent(id)}${node ? `?node=${encodeURIComponent(node)}` : ''}`);
2080
2106
  }
2081
2107
 
2082
- /** 회수 — 보낸 사람만. 이후 받는 쪽에서 본문이 안 보인다. */
2108
+ /** 회수 — 보낸 사람만. 받는 사람의 원본 접근이 바로 닫힌다. */
2083
2109
  async revokeDocMail(id) {
2084
2110
  return this.request(`/doc-mail/${encodeURIComponent(id)}/revoke`, { method: 'POST', body: {} });
2085
2111
  }
2086
2112
 
2113
+ // ---- Doc mail webhooks (DOC-MAIL-HOOK — 사람 단위) ---------------------
2114
+ /** 내 받은 문서 웹훅 만들기 — events: doc_mail.received|doc_mail.revoked|doc_mail.read(안 주면 셋 다). 비밀값은 이 응답에만. */
2115
+ async addDocMailWebhook({ url, events, name } = {}) {
2116
+ return this.request('/doc-mail-webhooks', { method: 'POST', body: { url, ...(events?.length ? { events } : {}), ...(name ? { name } : {}) } });
2117
+ }
2118
+
2119
+ async listDocMailWebhooks() {
2120
+ return this.request('/doc-mail-webhooks');
2121
+ }
2122
+
2123
+ async removeDocMailWebhook(id) {
2124
+ return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}`, { method: 'DELETE' });
2125
+ }
2126
+
2127
+ /** 시험 전송 한 통(doc_mail.test). 결과는 docMailWebhookDeliveries(id). */
2128
+ async testDocMailWebhook(id) {
2129
+ return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}/test`, { method: 'POST', body: {} });
2130
+ }
2131
+
2132
+ async docMailWebhookDeliveries(id) {
2133
+ return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}/deliveries`);
2134
+ }
2135
+
2136
+ /** 받은 사람의 권한 바꾸기 — 보낸 사람만, 내 수준까지. */
2137
+ async setDocMailAccess(id, access) {
2138
+ return this.request(`/doc-mail/${encodeURIComponent(id)}`, { method: 'PATCH', body: { access } });
2139
+ }
2140
+
2087
2141
  /** 내 문서함에서만 지우기(상대 화면은 그대로). box 로 나에게 보낸 것의 한쪽만 고를 수 있다. */
2088
2142
  async hideDocMail(id, { box } = {}) {
2089
2143
  return this.request(`/doc-mail/${encodeURIComponent(id)}${box ? `?box=${encodeURIComponent(box)}` : ''}`, {
package/lib/context.js CHANGED
@@ -36,3 +36,24 @@ export function withRateLimitSink(sink, fn) {
36
36
  export function currentRateLimitSink() {
37
37
  return rateLimitStorage.getStore();
38
38
  }
39
+
40
+ /**
41
+ * DOC-MAIL-NOTIFY — 안 읽은 받은 문서 알림을 모으는 그릇. `RootrClient.request` 가 그릇이 있으면 요청에
42
+ * `X-Rootr-Doc-Mail-Channel: <channel>` 을 붙이고, 응답의 `X-Rootr-Doc-Mail-Unread/New/New-Items` 를 여기 모은다(서버가
43
+ * 그 채널에 한 번만 알린다). MCP 는 도구 호출마다 async-local 그릇(channel 'mcp'), CLI 명령은 프로세스 전체 그릇(channel 'cli').
44
+ * 원격 MCP(API 프로세스 안)는 async-local 만 쓴다 — 전역 그릇은 CLI 실행 파일만 놓는다(여러 사람이 한 프로세스를 나눠 쓴다).
45
+ */
46
+ const docMailStorage = new AsyncLocalStorage();
47
+ let globalDocMailSink = null;
48
+
49
+ export function withDocMailSink(sink, fn) {
50
+ return docMailStorage.run(sink, fn);
51
+ }
52
+
53
+ export function setGlobalDocMailSink(sink) {
54
+ globalDocMailSink = sink;
55
+ }
56
+
57
+ export function currentDocMailSink() {
58
+ return docMailStorage.getStore() ?? globalDocMailSink;
59
+ }
@@ -3,40 +3,44 @@ import { z } from 'zod';
3
3
  import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
4
 
5
5
  /**
6
- * DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20).
6
+ * DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20, 같은 날 정정 "문서자체를 보내란 뜻임").
7
7
  *
8
- * 어떤 타입이든(문서·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를
9
- * **보낼 때의 복사본**으로 특정 이메일에게 보낸다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
10
- * 계정이 없어도 메일 링크로 열리고, 나중에 그 이메일로 가입하면 받은 문서함에 그대로 있다.
11
- * 보낸 뒤 원본을 고치거나 지워도 받은 문서는 그대로다(살아 있는 공유가 필요하면 권한/공개를 쓴다).
8
+ * 어떤 타입이든(문서·폴더·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를 특정 이메일에게
9
+ * **보낸다 = 원본 그 자체를 그 이메일에 열어 준다**(보기·댓글·편집). 복사본이 아니라서 보낸 사람이 고치면 받는 사람에게
10
+ * 바로 보이고, 편집을 받았으면 받는 사람이 고친 것이 원본에 남는다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
11
+ * 계정이 없어도 메일 링크(받은 이메일을 넣어야 열림, 읽기 전용)로 열리고, 그 이메일로 가입하면 문서함에서 원본이 열린다.
12
12
  */
13
13
  export function registerDocMailTools(server) {
14
14
  server.registerTool(
15
15
  'rootr_send_document',
16
16
  {
17
- title: 'Send a Rootr node to email addresses',
17
+ title: 'Send a Rootr node to email addresses (give them access to the original)',
18
18
  description:
19
- 'Send a SNAPSHOT of any Rootr node (document, table, spreadsheet, whiteboard, deck, form, issues, log, CRM, ' +
20
- 'recording, page, signed document — anything but a folder) to 1–20 email addresses. Each recipient gets an ' +
21
- 'email with a link that opens the copy WITHOUT a Rootr account; if they have (or later create) a Rootr account ' +
22
- 'with that email, it also sits in their inbox (rootr_list_doc_mail). The copy is frozen at send time — later ' +
23
- 'edits or deletion of the original do not change it; use permissions/publishing for a live share instead. ' +
24
- 'Needs WRITE on the node (the same bar as sharing). What never goes out: the same things a public page hides ' +
25
- '(person/attachment/relation columns, row documents, form responses, signer emails). ' +
26
- 'The result lists each recipient with `emailed` (false = the notification email failed; the copy still exists). ' +
27
- 'Only send to addresses the user asked for — this emails real people.',
19
+ 'Send any Rootr node (document, folder, table, spreadsheet, whiteboard, deck, form, issues, log, CRM, recording, ' +
20
+ 'page, signed document) to 1–20 email addresses. This is NOT a copy: each address gets access to the ORIGINAL ' +
21
+ 'node (and everything under it, if it is a folder) at `access` = read (view, default) | comment | write (edit). ' +
22
+ 'They always see the latest version; with write their edits land in the original. People with a Rootr account ' +
23
+ 'on that (verified) email open it from their inbox (rootr_list_doc_mail) with that access, even if they are not ' +
24
+ 'members of your workspace — they never see your workspace tree, members or search. People without an account ' +
25
+ 'get an email link where they must type their email address to read it (read-only). ' +
26
+ 'Needs WRITE on the node as a workspace member (the same bar as sharing); you can give at most your own level, ' +
27
+ 'never manage. Re-sending to the same address updates access/message instead of adding a second grant. ' +
28
+ 'Withdraw with rootr_revoke_doc_mail; change access with rootr_set_doc_mail_access. ' +
29
+ 'The result lists each recipient with `emailed` (false = the email failed; the access still stands). ' +
30
+ 'Only send to addresses the user asked for — this emails real people and opens the document to them.',
28
31
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
29
32
  inputSchema: {
30
33
  node: z.string().describe(`보낼 노드 id${NODE_TARGET_HINT}`),
31
34
  to: z.array(z.string()).min(1).max(20).describe('받는 사람 이메일 주소들(1~20개). 대소문자만 다른 주소는 같은 사람'),
32
35
  message: z.string().max(2000).optional().describe('보내며 함께 적을 말(메일과 문서함에 보인다, 2000자까지)'),
36
+ access: z.enum(['read', 'comment', 'write']).optional().describe('받는 사람이 원본에서 할 수 있는 것: read=보기(기본) · comment=댓글 · write=편집. 내 권한까지만'),
33
37
  workspace: z.string().optional().describe('경로로 줄 때 찾을 워크스페이스 id (생략하면 설정값)'),
34
38
  },
35
39
  },
36
- async ({ node, to, message, workspace }) => {
40
+ async ({ node, to, message, access, workspace }) => {
37
41
  try {
38
42
  const client = makeClient(workspace);
39
- return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message }));
43
+ return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message, access }));
40
44
  } catch (err) {
41
45
  return errorResult(err);
42
46
  }
@@ -51,7 +55,9 @@ export function registerDocMailTools(server) {
51
55
  "List the caller's document mailbox: box=inbox (documents sent TO the caller's account email — default) or " +
52
56
  'box=sent (documents the caller sent). Summaries only (title, type, sender, recipient, message, sentAt, ' +
53
57
  'readAt, revoked); open one with rootr_read_doc_mail. `unread` is the number of unread received documents. ' +
54
- 'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace.',
58
+ 'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace. ' +
59
+ 'Each item has `access` (READ/COMMENT/WRITE), `original` (live | trashed | deleted) and, for live received ones, ' +
60
+ '`nodeId`/`workspaceId` of the original.',
55
61
  annotations: { readOnlyHint: true },
56
62
  inputSchema: {
57
63
  box: z.enum(['inbox', 'sent']).optional().describe('inbox=받은 문서(기본), sent=보낸 문서'),
@@ -74,18 +80,21 @@ export function registerDocMailTools(server) {
74
80
  {
75
81
  title: 'Read a received or sent Rootr document',
76
82
  description:
77
- 'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus `payload` — the ' +
78
- 'snapshot taken at send time, in the same shape a public page uses: `kind` says what it is (doc → `content` ' +
79
- 'markdown; database → `properties` + `rows`; spreadsheet → `data`; …). Opening a received one marks it read. ' +
80
- 'If the sender withdrew it, `revoked` is true and `payload` is null.',
83
+ 'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus the CURRENT content ' +
84
+ 'of the original, read-only, in the same shape a public page uses: `contentMd` for documents, `payload.kind` for ' +
85
+ 'the rest (database → `properties` + `rows`; spreadsheet → `data`; …), `children` (items inside — pass one as ' +
86
+ '`node` to read it), `access` (what the recipient may do), `openUrl` (the original in the Rootr editor). ' +
87
+ 'Opening a received one marks it read. Withdrawn → `revoked` true and no content; original deleted → ' +
88
+ '`originalDeleted` true. This works with any of the recipient\'s keys, even one bound to another workspace.',
81
89
  annotations: { readOnlyHint: false },
82
90
  inputSchema: {
83
91
  id: z.string().describe('문서함 항목 id (rootr_list_doc_mail 의 items[].id)'),
92
+ node: z.string().optional().describe('폴더(부모)를 받았을 때 그 아래 노드 id (앞 응답의 children[].id)'),
84
93
  },
85
94
  },
86
- async ({ id }) => {
95
+ async ({ id, node }) => {
87
96
  try {
88
- return jsonResult(await makeClient().getDocMail(id));
97
+ return jsonResult(await makeClient().getDocMail(id, { node }));
89
98
  } catch (err) {
90
99
  return errorResult(err);
91
100
  }
@@ -97,9 +106,9 @@ export function registerDocMailTools(server) {
97
106
  {
98
107
  title: 'Withdraw a sent Rootr document',
99
108
  description:
100
- 'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent). From then on the recipient sees ' +
101
- 'only the title and "withdrawn" — no content — and the email link answers 410. Only the sender can do this; ' +
102
- 'it cannot be undone (send it again instead). Copies the recipient already saved elsewhere are out of reach.',
109
+ 'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent). The recipient loses access to the ' +
110
+ 'original immediately (open editors are disconnected), sees only the title and "withdrawn", and the email link ' +
111
+ 'answers 410. Only the sender can do this; it cannot be undone (send it again instead).',
103
112
  annotations: { readOnlyHint: false, destructiveHint: true },
104
113
  inputSchema: {
105
114
  id: z.string().describe('보낸 문서함 항목 id'),
@@ -113,4 +122,117 @@ export function registerDocMailTools(server) {
113
122
  }
114
123
  },
115
124
  );
125
+
126
+ server.registerTool(
127
+ 'rootr_set_doc_mail_access',
128
+ {
129
+ title: 'Change what the recipient of a sent Rootr document can do',
130
+ description:
131
+ 'Change the access of one recipient of a document the caller sent (id from rootr_list_doc_mail box=sent): ' +
132
+ 'read (view) | comment | write (edit). Only the sender; at most the sender\'s own level on the node. Lowering it ' +
133
+ 'takes effect immediately. No email is sent.',
134
+ annotations: { readOnlyHint: false, destructiveHint: false },
135
+ inputSchema: {
136
+ id: z.string().describe('보낸 문서함 항목 id'),
137
+ access: z.enum(['read', 'comment', 'write']).describe('read=보기 · comment=댓글 · write=편집'),
138
+ },
139
+ },
140
+ async ({ id, access }) => {
141
+ try {
142
+ return jsonResult(await makeClient().setDocMailAccess(id, access));
143
+ } catch (err) {
144
+ return errorResult(err);
145
+ }
146
+ },
147
+ );
148
+
149
+ // ── DOC-MAIL-HOOK — 사람 단위 웹훅 ──────────────────────────────────────
150
+ server.registerTool(
151
+ 'rootr_add_doc_mail_webhook',
152
+ {
153
+ title: 'Get a webhook when documents arrive for me',
154
+ description:
155
+ "Register a webhook on the caller's own account (not a workspace) for their document mailbox. Rootr POSTs JSON to " +
156
+ '`url` on doc_mail.received (a document was sent to the account\'s verified email), doc_mail.revoked (a received one ' +
157
+ 'was withdrawn) and doc_mail.read (a recipient opened one the caller sent). The body has id, title, nodeType, sender, ' +
158
+ 'message, access, apiUrl (GET it with your key for the content) and appUrl — never the document content. Each request ' +
159
+ 'is signed: X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body). The `secret` is returned ONCE, here — store it. ' +
160
+ 'Only public http(s) URLs (private/loopback addresses are refused). Max 5 per account.',
161
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
162
+ inputSchema: {
163
+ url: z.string().describe('받을 주소(공인 http/https)'),
164
+ events: z.array(z.enum(['doc_mail.received', 'doc_mail.revoked', 'doc_mail.read'])).optional().describe('받을 사건(안 주면 셋 다)'),
165
+ name: z.string().max(100).optional().describe('알아보기 쉬운 이름'),
166
+ },
167
+ },
168
+ async ({ url, events, name }) => {
169
+ try {
170
+ return jsonResult(await makeClient().addDocMailWebhook({ url, events, name }));
171
+ } catch (err) {
172
+ return errorResult(err);
173
+ }
174
+ },
175
+ );
176
+
177
+ server.registerTool(
178
+ 'rootr_list_doc_mail_webhooks',
179
+ {
180
+ title: "List my document webhooks",
181
+ description: "List the caller's document-mailbox webhooks (url, events, active, last delivery status). Secrets are not shown.",
182
+ annotations: { readOnlyHint: true },
183
+ inputSchema: {},
184
+ },
185
+ async () => {
186
+ try {
187
+ return jsonResult(await makeClient().listDocMailWebhooks());
188
+ } catch (err) {
189
+ return errorResult(err);
190
+ }
191
+ },
192
+ );
193
+
194
+ server.registerTool(
195
+ 'rootr_remove_doc_mail_webhook',
196
+ {
197
+ title: 'Remove one of my document webhooks',
198
+ description: 'Delete a document-mailbox webhook of the caller (id from rootr_list_doc_mail_webhooks). Its delivery log goes with it.',
199
+ annotations: { readOnlyHint: false, destructiveHint: true },
200
+ inputSchema: { id: z.string().describe('웹훅 id') },
201
+ },
202
+ async ({ id }) => {
203
+ try {
204
+ return jsonResult(await makeClient().removeDocMailWebhook(id));
205
+ } catch (err) {
206
+ return errorResult(err);
207
+ }
208
+ },
209
+ );
210
+
211
+ server.registerTool(
212
+ 'rootr_test_doc_mail_webhook',
213
+ {
214
+ title: 'Send a test event to one of my document webhooks',
215
+ description:
216
+ 'Queue a signed doc_mail.test delivery to the webhook (re-enables it if it was turned off), then report the latest ' +
217
+ 'deliveries a few seconds later (status, HTTP code, error).',
218
+ annotations: { readOnlyHint: false, openWorldHint: true },
219
+ inputSchema: { id: z.string().describe('웹훅 id') },
220
+ },
221
+ async ({ id }) => {
222
+ try {
223
+ const client = makeClient();
224
+ const queued = await client.testDocMailWebhook(id);
225
+ let deliveries = null;
226
+ for (let i = 0; i < 8; i++) {
227
+ await new Promise((r) => setTimeout(r, 750));
228
+ deliveries = await client.docMailWebhookDeliveries(id);
229
+ const d = deliveries?.items?.find((x) => x.id === queued.deliveryId);
230
+ if (d && d.status !== 'PENDING') break;
231
+ }
232
+ return jsonResult({ ...queued, deliveries: deliveries?.items?.slice(0, 5) ?? [] });
233
+ } catch (err) {
234
+ return errorResult(err);
235
+ }
236
+ },
237
+ );
116
238
  }
@@ -109,7 +109,8 @@ export function registerPageTools(server) {
109
109
  title: 'Create a Rootr page (block-assembled web page)',
110
110
  description:
111
111
  'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
112
- 'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
112
+ 'can then be published to the web at a public address (see rootr_publish_node — web publishing of pages needs ' +
113
+ 'the Team plan; on Free the page still works inside the workspace). Use this when someone asks ' +
113
114
  'for a DASHBOARD / report / KPI board over their tables, spreadsheets or logs (46 live chart kinds, numbers stay ' +
114
115
  'current), or for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
115
116
  'Omit `blocks` to start from a hero/text/footer skeleton. ' +
@@ -29,9 +29,13 @@ export function registerPublicTools(server) {
29
29
  description:
30
30
  'Publish ANY Rootr (루터) node at a public read-only URL (rootr.io/p/{slug}) — a DATABASE (visitors get a ' +
31
31
  'searchable table and can clone it into their own workspace), SPREADSHEET, WHITEBOARD, FORM (questions only), ' +
32
- 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED or DOCUMENT. Use this instead of ' +
32
+ 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED, DOCUMENT or PAGE. Use this instead of ' +
33
33
  'rootr_publish_document unless you specifically want the document-only extras (skins, edit proposals). ' +
34
34
  'FOLDER cannot be published. ' +
35
+ 'PAGE (the block-assembled web page) can be published only on the Team plan and up — a Free workspace gets ' +
36
+ '403 PLAN_UPGRADE_REQUIRED (nothing is changed; tell the user to upgrade the workspace to Team). Every other type ' +
37
+ 'publishes on Free as before. With `includeChildren` on Free, PAGE nodes underneath are skipped and the response ' +
38
+ 'says how many (`skippedPagesRequireTeam`). ' +
35
39
  'Set `includeChildren: true` to publish everything underneath as well — the WHOLE subtree, grandchildren ' +
36
40
  'included, each getting its own URL and listed on the parent page. Leave it off and only this one node goes ' +
37
41
  'out (its sub-page names do not even appear). Turning it back off, closing the parent, moving a child out, ' +
@@ -90,6 +94,7 @@ export function registerPublicTools(server) {
90
94
  'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
91
95
  '`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
92
96
  'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
97
+ '`{"isPublic": true}` on a PAGE needs the Team plan (Free → 403 PLAN_UPGRADE_REQUIRED); closing it works on any plan. ' +
93
98
  'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
94
99
  'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
95
100
  annotations: { readOnlyHint: false, destructiveHint: false },
@@ -132,6 +137,8 @@ export function registerPublicTools(server) {
132
137
  '`publishable: false` means this type cannot be published at all (FOLDER). ' +
133
138
  '`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
134
139
  'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
140
+ '`closed: "PAGE_PUBLISH_REQUIRES_TEAM"` means a PAGE was published but its workspace is on the Free plan, so ' +
141
+ 'visitors see a "not published" screen — upgrading to Team reopens the same URL without publishing again. ' +
135
142
  'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
136
143
  annotations: { readOnlyHint: true, destructiveHint: false },
137
144
  inputSchema: {
package/lib/mcp.js CHANGED
@@ -25,7 +25,7 @@ import { registerSignedDocTools } from './mcp-tools/signed.js';
25
25
  import { registerFeedbackTools } from './mcp-tools/feedback.js';
26
26
  import { registerMemoryTools } from './mcp-tools/memory.js';
27
27
  import { registerDocMailTools } from './mcp-tools/doc-mail.js';
28
- import { withRateLimitSink } from './context.js';
28
+ import { withDocMailSink, withRateLimitSink } from './context.js';
29
29
  import { makeClient } from './mcp-tools/shared.js';
30
30
  import { t as tr } from './i18n.js';
31
31
 
@@ -77,6 +77,39 @@ function withQuotaWarnings(server) {
77
77
  }
78
78
 
79
79
 
80
+ /**
81
+ * DOC-MAIL-NOTIFY — 어떤 루터 도구를 부르든, 아직 알리지 않은 **새 받은 문서**가 있으면 응답 끝에 한 줄을 붙인다(창업자 지시
82
+ * 2026-10-20 — 클라이언트와 상관없이 루터를 쓰는 순간 알게). 도구가 부른 API 응답의 머리줄(`X-Rootr-Doc-Mail-New-Items`)을
83
+ * 그릇에 모아 쓴다 — 따로 묻는 호출이 없다. 서버가 키마다·채널(mcp)마다 한 번만 알려 준다(같은 문서를 두 번 알리지 않는다).
84
+ * 문서함 도구 자신(`*doc_mail*`)의 응답에는 붙이지 않는다 — 이미 문서함을 보고 있다.
85
+ */
86
+ function docMailNotice(items) {
87
+ if (!items?.length) return null;
88
+ const shown = items.slice(0, 5);
89
+ const list = shown.map((i) => tr(`「${i.title}」 — ${i.sender} (id: ${i.id})`, `"${i.title}" from ${i.sender} (id: ${i.id})`)).join(', ');
90
+ const more = items.length > shown.length ? tr(` 외 ${items.length - shown.length}건`, ` and ${items.length - shown.length} more`) : '';
91
+ return tr(
92
+ `\n\n📬 새로 받은 문서 (${items.length}): ${list}${more} — rootr_read_doc_mail {id} 로 읽으세요. 사용자에게 알려 주세요.`,
93
+ `\n\n📬 New documents received (${items.length}): ${list}${more} — read with rootr_read_doc_mail {id}. Tell the user.`,
94
+ );
95
+ }
96
+
97
+ function withDocMailNotices(server) {
98
+ const original = server.registerTool.bind(server);
99
+ server.registerTool = (name, spec, handler) =>
100
+ original(name, spec, async (...args) => {
101
+ const sink = { channel: 'mcp', items: [] };
102
+ const result = await withDocMailSink(sink, () => handler(...args));
103
+ if (/doc_mail/.test(name)) return result;
104
+ const notice = docMailNotice(sink.items);
105
+ if (!notice || !result || !Array.isArray(result.content)) return result;
106
+ const last = [...result.content].reverse().find((c) => c && c.type === 'text');
107
+ if (!last) return { ...result, content: [...result.content, { type: 'text', text: notice.trim() }] };
108
+ return { ...result, content: result.content.map((c) => (c === last ? { ...c, text: `${c.text}${notice}` } : c)) };
109
+ });
110
+ return server;
111
+ }
112
+
80
113
  /**
81
114
  * MEM-001 — **기억을 눈앞에 놓는다. 이 기능의 성패가 여기 달렸다.**
82
115
  *
@@ -225,10 +258,14 @@ export function buildMcpServer({ name, version }) {
225
258
  'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
226
259
  'that code is the only evidence a person (not an agent) pressed sign; ' +
227
260
  'FOLDER = grouping; LOG store = typed event table whose relation fields auto-build a lineage graph (metrics, incidents, pipeline events — use the rootr_*_log_* tools).\n\n' +
228
- 'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document mails a frozen COPY of any ' +
229
- 'node (not a folder) to 1-20 addresses — no Rootr account needed to open it, and it lands in their inbox if ' +
230
- 'that email has an account; rootr_list_doc_mail (box inbox|sent) / rootr_read_doc_mail / rootr_revoke_doc_mail ' +
231
- 'cover the mailbox. Only send to addresses the user gave you.\n\n' +
261
+ 'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document gives 1-20 email addresses ' +
262
+ 'access to the ORIGINAL node (not a copy) at access read|comment|write — they see live edits, and with write ' +
263
+ 'their edits land in the original; an account on that email opens it from its inbox, others read it via the ' +
264
+ 'email link after typing their address; rootr_list_doc_mail (box inbox|sent) / rootr_read_doc_mail / ' +
265
+ 'rootr_set_doc_mail_access / rootr_revoke_doc_mail cover the mailbox. Only send to addresses the user gave you. ' +
266
+ 'When any tool result ends with "📬 New documents received", tell the user right away (title and sender) and offer to ' +
267
+ 'open them with rootr_read_doc_mail. rootr_add_doc_mail_webhook makes Rootr POST doc_mail.received / revoked / read ' +
268
+ 'to the user\'s own URL (for their automations).\n\n' +
232
269
  'Scaffolding discipline — treat "set up a workspace for X" exactly like "build me this app": you ask a ' +
233
270
  'few clarifying questions, write it, compile it, run the tests, leave a README, and report. The user ' +
234
271
  'should only have to type that one sentence; everything else finishes inside the conversation. Never ask ' +
@@ -268,6 +305,7 @@ export function buildMcpServer({ name, version }) {
268
305
  // 한도 경고가 그 뒤에 붙는다 — 경고는 언제나 맨 끝에 있어야 눈에 띈다.
269
306
  withMemoryRecall(server);
270
307
  withQuotaWarnings(server);
308
+ withDocMailNotices(server);
271
309
 
272
310
  registerDocumentTools(server);
273
311
  registerTemplateTools(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {