pombus 0.1.1 → 0.1.2

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Runaki
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Runaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,79 +1,79 @@
1
- # Pombus MCP server
2
-
3
- Email boxes for AI agents — the opposite of a webmail client. Pombus gives a
4
- person and their AI agents their own mailboxes; the agent operates the mailbox
5
- over the [Model Context Protocol](https://modelcontextprotocol.io) while the
6
- human stays in the loop.
7
-
8
- This package is the MCP server: a thin stdio translator between a client's MCP
9
- tool calls and the Pombus REST API. It holds no mail and no logic of its own —
10
- every read, draft and send is a call to the Pombus core. Learn more at
11
- [pombus.com](https://pombus.com).
12
-
13
- <!-- mcp-name: com.pombus/pombus -->
14
-
15
- ## Install
16
-
17
- One command wires Pombus into your MCP client:
18
-
19
- ```sh
20
- npx pombus init
21
- ```
22
-
23
- `init` reads your API key, detects your client, and writes the MCP server block
24
- into its config (merging, never clobbering the rest of the file). Detected
25
- clients: **Claude Code**, **Cursor**, **Gemini CLI**, and **Codex CLI** (Codex
26
- uses TOML, so `init` prints the block for you to paste).
27
-
28
- ```sh
29
- npx pombus init --print # print the config block, write nothing
30
- npx pombus init --client cursor # target one client
31
- npx pombus init --create-inbox # create an inbox during setup
32
- ```
33
-
34
- ## Commands
35
-
36
- | Command | What it does |
37
- | --------------- | ------------------------------------------------------------------- |
38
- | `pombus mcp` | Run the stdio MCP server. Your client invokes this; you rarely do. |
39
- | `pombus init` | Write the Pombus MCP config into your client. |
40
- | `pombus doctor` | Validate your API key and report its scope. Read-only, one request. |
41
-
42
- ## Authentication
43
-
44
- Set `POMBUS_API_KEY` in the environment. `init` writes it into the MCP server
45
- block for you; the key never lands in your terminal scrollback. Get a key at
46
- [pombus.com](https://pombus.com).
47
-
48
- ```json
49
- {
50
- "mcpServers": {
51
- "pombus": {
52
- "command": "npx",
53
- "args": ["-y", "pombus", "mcp"],
54
- "env": { "POMBUS_API_KEY": "<YOUR_POMBUS_API_KEY>" }
55
- }
56
- }
57
- }
58
- ```
59
-
60
- ## The tools are gated by your key
61
-
62
- The server exposes a catalog of 53 tools — list and read threads, search, label,
63
- compose and send drafts, manage lists, read metrics. But **your API key decides
64
- what the agent actually sees.** A tool is only registered when the key holds the
65
- permission that tool needs; a key without send permission never sees a send
66
- tool, so the agent cannot call it. The same key also fixes the scope: an
67
- inbox-scoped key binds a single inbox, and tools drop the `inbox_id` argument
68
- entirely — the agent physically cannot address another inbox.
69
-
70
- **Draft-first is the default write path.** Composing a message creates a draft
71
- (`create_draft`) for a human to review; sending is a separate, explicit step
72
- (`send_draft`). Direct, unreviewed send exists only for keys that explicitly
73
- hold send permission — most keys stay draft-only.
74
-
75
- Run `pombus doctor` to see exactly which scope your key resolves to.
76
-
77
- ## License
78
-
79
- [MIT](./LICENSE) © Runaki
1
+ # Pombus MCP server
2
+
3
+ Email boxes for AI agents — the opposite of a webmail client. Pombus gives a
4
+ person and their AI agents their own mailboxes; the agent operates the mailbox
5
+ over the [Model Context Protocol](https://modelcontextprotocol.io) while the
6
+ human stays in the loop.
7
+
8
+ This package is the MCP server: a thin stdio translator between a client's MCP
9
+ tool calls and the Pombus REST API. It holds no mail and no logic of its own —
10
+ every read, draft and send is a call to the Pombus core. Learn more at
11
+ [pombus.com](https://pombus.com).
12
+
13
+ <!-- mcp-name: com.pombus/pombus -->
14
+
15
+ ## Install
16
+
17
+ One command wires Pombus into your MCP client:
18
+
19
+ ```sh
20
+ npx pombus init
21
+ ```
22
+
23
+ `init` reads your API key, detects your client, and writes the MCP server block
24
+ into its config (merging, never clobbering the rest of the file). Detected
25
+ clients: **Claude Code**, **Cursor**, **Gemini CLI**, and **Codex CLI** (Codex
26
+ uses TOML, so `init` prints the block for you to paste).
27
+
28
+ ```sh
29
+ npx pombus init --print # print the config block, write nothing
30
+ npx pombus init --client cursor # target one client
31
+ npx pombus init --create-inbox # create an inbox during setup
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ | Command | What it does |
37
+ | --------------- | ------------------------------------------------------------------- |
38
+ | `pombus mcp` | Run the stdio MCP server. Your client invokes this; you rarely do. |
39
+ | `pombus init` | Write the Pombus MCP config into your client. |
40
+ | `pombus doctor` | Validate your API key and report its scope. Read-only, one request. |
41
+
42
+ ## Authentication
43
+
44
+ Set `POMBUS_API_KEY` in the environment. `init` writes it into the MCP server
45
+ block for you; the key never lands in your terminal scrollback. Get a key at
46
+ [pombus.com](https://pombus.com).
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "pombus": {
52
+ "command": "npx",
53
+ "args": ["-y", "pombus", "mcp"],
54
+ "env": { "POMBUS_API_KEY": "<YOUR_POMBUS_API_KEY>" }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## The tools are gated by your key
61
+
62
+ The server exposes a catalog of 53 tools — list and read threads, search, label,
63
+ compose and send drafts, manage lists, read metrics. But **your API key decides
64
+ what the agent actually sees.** A tool is only registered when the key holds the
65
+ permission that tool needs; a key without send permission never sees a send
66
+ tool, so the agent cannot call it. The same key also fixes the scope: an
67
+ inbox-scoped key binds a single inbox, and tools drop the `inbox_id` argument
68
+ entirely — the agent physically cannot address another inbox.
69
+
70
+ **Draft-first is the default write path.** Composing a message creates a draft
71
+ (`create_draft`) for a human to review; sending is a separate, explicit step
72
+ (`send_draft`). Direct, unreviewed send exists only for keys that explicitly
73
+ hold send permission — most keys stay draft-only.
74
+
75
+ Run `pombus doctor` to see exactly which scope your key resolves to.
76
+
77
+ ## License
78
+
79
+ [MIT](./LICENSE) © Runaki
package/dist/bin.js CHANGED
@@ -85,7 +85,7 @@ async function main() {
85
85
  print: args.print,
86
86
  ...(args.client !== undefined ? { client: args.client } : {}),
87
87
  ...(args.locale !== undefined ? { locale: args.locale } : {}),
88
- home: undefined, // defaults to os.homedir() inside clients.ts
88
+ home: undefined, // init.ts homeOf() falls back to os.homedir(); clients.ts refuses an unsafe HOME
89
89
  promptKey: promptKeyHidden,
90
90
  });
91
91
  return;
package/dist/clients.js CHANGED
@@ -15,9 +15,9 @@
15
15
  // - PRINT fallback: a client whose format we will not write by hand (Codex: TOML, which we will not
16
16
  // serialize without a parser — ponytail forbids the dep, and a half-escaped TOML path is a known
17
17
  // way to wedge the client) is PRINT-only. So is any client whose config dir we cannot resolve.
18
- import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs";
18
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, rmSync, existsSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { join, dirname, resolve, sep } from "node:path";
20
+ import { join, dirname, resolve, sep, isAbsolute } from "node:path";
21
21
  /** Resolve the table of known clients for a given HOME. Taking `home` as an argument (not calling
22
22
  * os.homedir() inline) is what lets a test point the whole table at a temp dir. */
23
23
  export function clientTargets(home = homedir()) {
@@ -98,6 +98,12 @@ export class ConfigWriteError extends Error {
98
98
  /** True when `target` is inside `home` (defense against a client table — or a tampered HOME env — that
99
99
  * would place a write in the project repo or elsewhere). Path-prefix check after resolving both. */
100
100
  export function isInsideHome(target, home) {
101
+ // A HOME that is empty or relative ("", ".") resolves against the cwd, and a HOME that is itself a git
102
+ // repo (a container with HOME=/app=WORKDIR) is a project: either way the key would land in a repo.
103
+ // The caller falls back to --print. (Target and home derive from the same string, so this guard — not
104
+ // the prefix check below — is what catches a misconfigured HOME.)
105
+ if (!home.trim() || !isAbsolute(home) || existsSync(join(home, ".git")))
106
+ return false;
101
107
  const h = resolve(home);
102
108
  const t = resolve(target);
103
109
  return t === h || t.startsWith(h + sep);
package/dist/i18n.js CHANGED
@@ -55,6 +55,11 @@ const MESSAGES = {
55
55
  "pt-BR": "Config MCP gravada para ",
56
56
  "es-419": "Configuración MCP guardada para ",
57
57
  },
58
+ init_key_power: {
59
+ "en-US": "Heads up: your agent can do everything this key can — read, send and delete mail, and manage keys and webhooks. The key is stored in plain text in that config file; revoke it in Pombus › Keys if it leaks.",
60
+ "pt-BR": "Atenção: seu agente pode fazer tudo que esta chave permite — ler, enviar e apagar e-mails, e gerenciar chaves e webhooks. A chave fica em texto puro nesse arquivo de config; revogue em Pombus › Chaves se ela vazar.",
61
+ "es-419": "Atención: tu agente puede hacer todo lo que esta clave permite — leer, enviar y borrar correos, y administrar claves y webhooks. La clave queda en texto plano en ese archivo de configuración; revócala en Pombus › Claves si se filtra.",
62
+ },
58
63
  init_restart_client: {
59
64
  "en-US": "Restart your client to load the Pombus MCP server.",
60
65
  "pt-BR": "Reinicie seu cliente para carregar o servidor MCP do Pombus.",
@@ -91,6 +96,7 @@ const INSTALLER_CODES = [
91
96
  "init_prompt_key",
92
97
  "init_inbox_created",
93
98
  "init_config_written",
99
+ "init_key_power",
94
100
  "init_restart_client",
95
101
  "init_done",
96
102
  "init_no_client",
package/dist/init.js CHANGED
@@ -10,6 +10,7 @@ import { CoreClient } from "./client.js";
10
10
  import { ScopeBinding } from "./scope.js";
11
11
  import { clientTargets, printableCodexToml, printableJson, writeJsonConfig, ConfigWriteError, } from "./clients.js";
12
12
  import { humanMessage, resolveLocale } from "./i18n.js";
13
+ import { homedir } from "node:os";
13
14
  /** A placeholder shown in --print output instead of a real key, so a user can paste the block and fill
14
15
  * the key in themselves without a secret ever reaching the terminal scrollback. */
15
16
  const KEY_PLACEHOLDER = "<YOUR_POMBUS_API_KEY>";
@@ -45,6 +46,7 @@ export async function runInit(opts) {
45
46
  // --print mode: show the real-target block(s) and write nothing.
46
47
  if (opts.print) {
47
48
  printBlocks(key, opts, out, locale);
49
+ say("init_key_power"); // the REAL key was just printed for paste (the placeholder path above isn't)
48
50
  return {
49
51
  printed: true,
50
52
  inboxId,
@@ -81,15 +83,17 @@ export async function runInit(opts) {
81
83
  }
82
84
  }
83
85
  }
84
- if (written.length > 0) {
85
- say("init_restart_client");
86
- say("init_done");
87
- }
88
- else if (printedFor.length === 0) {
86
+ if (written.length === 0 && printedFor.length === 0) {
89
87
  // No target at all resolved — detection was too uncertain to touch anything: print for everyone.
90
88
  say("init_no_client");
91
89
  out(printableJson(key));
92
90
  }
91
+ // Whether written or printed for paste, the real key now sits in a config: say what it can do.
92
+ say("init_key_power");
93
+ if (written.length > 0) {
94
+ say("init_restart_client");
95
+ say("init_done");
96
+ }
93
97
  return {
94
98
  printed: false,
95
99
  inboxId,
@@ -106,8 +110,10 @@ function targetsFor(opts) {
106
110
  const one = all.find((t) => t.id === opts.client);
107
111
  return one ? [one] : [];
108
112
  }
113
+ // The CLI passes no home: it MUST be the user's real HOME. An empty string here resolved every client
114
+ // path against the cwd and wrote the key in plain text into the project the user ran init from (0.1.0).
109
115
  function homeOf(opts) {
110
- return opts.home ?? "";
116
+ return opts.home || homedir();
111
117
  }
112
118
  /** Print the MCP block for each targeted client in its own format (JSON for most, TOML for Codex). */
113
119
  function printBlocks(key, opts, out, locale) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pombus",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pombus MCP server — email boxes for AI agents. A thin stdio translator between MCP tool calls and the Pombus REST core.",
5
5
  "license": "MIT",
6
6
  "author": "Runaki",
@@ -20,7 +20,8 @@
20
20
  },
21
21
  "files": [
22
22
  "dist/**/*.js",
23
- "!dist/**/*.test.js"
23
+ "!dist/**/*.test.js",
24
+ "!dist/testutil.js"
24
25
  ],
25
26
  "engines": {
26
27
  "node": ">=20"
package/dist/testutil.js DELETED
@@ -1,117 +0,0 @@
1
- // Test doubles — a fake core (a FetchLike that records every request and returns programmed replies)
2
- // and a captured-request shape the tests assert against. Not a *.test.ts, so the test runner does not
3
- // execute it as a suite. All fixtures are written from scratch (AGENTS.md §9): invented ids, no real
4
- // data.
5
- export class FakeCore {
6
- requests = [];
7
- routes = [];
8
- /** Register a route. Routes are matched in order; the first match wins. */
9
- on(method, match, reply) {
10
- this.routes.push({ method, match, reply });
11
- return this;
12
- }
13
- /** A FetchLike bound to this fake. Records the request, then returns the first matching route's
14
- * reply, or a 404 when nothing matches (so an unexpected call fails closed, like the real core). */
15
- fetch = async (rawUrl, init) => {
16
- const url = new URL(rawUrl);
17
- const headers = lowerHeaders(init.headers);
18
- let body;
19
- if (typeof init.body === "string" && init.body.length > 0) {
20
- try {
21
- body = JSON.parse(init.body);
22
- }
23
- catch {
24
- body = init.body;
25
- }
26
- }
27
- const captured = { method: init.method ?? "GET", url: rawUrl, headers, body };
28
- this.requests.push(captured);
29
- const route = this.routes.find((r) => r.method === captured.method && r.match(url));
30
- const reply = route
31
- ? typeof route.reply === "function"
32
- ? route.reply(captured, url)
33
- : route.reply
34
- : { status: 404, json: { code: "not_found" } };
35
- return makeResponse(reply);
36
- };
37
- }
38
- function lowerHeaders(h) {
39
- const out = {};
40
- if (!h)
41
- return out;
42
- if (h instanceof Headers) {
43
- h.forEach((v, k) => (out[k.toLowerCase()] = v));
44
- }
45
- else if (Array.isArray(h)) {
46
- for (const pair of h) {
47
- const k = pair[0];
48
- const v = pair[1];
49
- if (k !== undefined && v !== undefined)
50
- out[k.toLowerCase()] = v;
51
- }
52
- }
53
- else {
54
- for (const [k, v] of Object.entries(h))
55
- out[k.toLowerCase()] = String(v);
56
- }
57
- return out;
58
- }
59
- function makeResponse(reply) {
60
- const status = reply.status ?? 200;
61
- const hasBody = reply.json !== undefined;
62
- // A 204/205 MUST have a null body (undici throws on any body, even ""), matching the real core's
63
- // no-content replies (e.g. a DELETE).
64
- const nullBodyStatus = status === 204 || status === 205 || status === 304;
65
- const body = hasBody ? JSON.stringify(reply.json) : nullBodyStatus ? null : "";
66
- return new Response(body, {
67
- status,
68
- headers: hasBody ? { "content-type": "application/json" } : {},
69
- });
70
- }
71
- /** An /auth/me reply for an inbox-scoped key (single implied inbox). */
72
- export function authMeInbox(inboxId) {
73
- return {
74
- json: {
75
- scope_type: "inbox",
76
- scope_id: inboxId,
77
- organization_id: "org_test",
78
- pod_id: "pod_test",
79
- inbox_id: inboxId,
80
- api_key_id: "key_test",
81
- permissions: null,
82
- },
83
- };
84
- }
85
- /** An /auth/me reply for a pod-scoped key (reaches many inboxes). */
86
- export function authMePod() {
87
- return {
88
- json: {
89
- scope_type: "pod",
90
- scope_id: "pod_test",
91
- organization_id: "org_test",
92
- pod_id: "pod_test",
93
- inbox_id: null,
94
- api_key_id: "key_test",
95
- permissions: null,
96
- },
97
- };
98
- }
99
- /** A POST /v0/inboxes reply (the shape the core returns: inbox_id + email). Invented ids only (§9). */
100
- export function createdInbox(inboxId, email) {
101
- return { json: { inbox_id: inboxId, email } };
102
- }
103
- /** An /auth/me reply for a SCOPED pod key — a real permissions object drives the catalog gate. Pass
104
- * the permissions the human granted the key; everything else uses invented ids (§9). */
105
- export function authMeScoped(permissions, overrides = {}) {
106
- return {
107
- json: {
108
- scope_type: overrides.scope_type ?? "pod",
109
- scope_id: "pod_test",
110
- organization_id: "org_test",
111
- pod_id: overrides.pod_id === undefined ? "pod_test" : overrides.pod_id,
112
- inbox_id: overrides.inbox_id ?? null,
113
- api_key_id: "key_test",
114
- permissions,
115
- },
116
- };
117
- }