pombus 0.1.0 → 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 ADDED
@@ -0,0 +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.
package/README.md ADDED
@@ -0,0 +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
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,15 +1,27 @@
1
1
  {
2
2
  "name": "pombus",
3
- "version": "0.1.0",
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
- "license": "UNLICENSED",
6
- "private": false,
5
+ "license": "MIT",
6
+ "author": "Runaki",
7
+ "homepage": "https://pombus.com",
8
+ "keywords": [
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "email",
12
+ "ai-agent",
13
+ "agent-email",
14
+ "inbox"
15
+ ],
16
+ "mcpName": "com.pombus/pombus",
7
17
  "type": "module",
8
18
  "bin": {
9
19
  "pombus": "dist/bin.js"
10
20
  },
11
21
  "files": [
12
- "dist"
22
+ "dist/**/*.js",
23
+ "!dist/**/*.test.js",
24
+ "!dist/testutil.js"
13
25
  ],
14
26
  "engines": {
15
27
  "node": ">=20"
package/dist/bin.test.js DELETED
@@ -1,33 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { parseArgs } from "./bin.js";
4
- test("parseArgs: command + flags", () => {
5
- const a = parseArgs(["init", "--create-inbox", "--print"]);
6
- assert.equal(a.cmd, "init");
7
- assert.equal(a.createInbox, true);
8
- assert.equal(a.print, true);
9
- });
10
- test("parseArgs: --dry-run is an alias for --print", () => {
11
- assert.equal(parseArgs(["init", "--dry-run"]).print, true);
12
- });
13
- test("parseArgs: --locale with a space or an = ", () => {
14
- assert.equal(parseArgs(["init", "--locale", "pt-BR"]).locale, "pt-BR");
15
- assert.equal(parseArgs(["init", "--locale=es-419"]).locale, "es-419");
16
- });
17
- test("parseArgs: an invalid locale is dropped (falls back to env detection)", () => {
18
- assert.equal(parseArgs(["init", "--locale", "klingon"]).locale, undefined);
19
- });
20
- test("parseArgs: --client selects one target", () => {
21
- assert.equal(parseArgs(["init", "--client", "cursor"]).client, "cursor");
22
- assert.equal(parseArgs(["init", "--client=codex"]).client, "codex");
23
- });
24
- test("parseArgs: --help, and no command", () => {
25
- assert.equal(parseArgs(["--help"]).help, true);
26
- assert.equal(parseArgs(["doctor", "-h"]).help, true);
27
- assert.equal(parseArgs([]).cmd, undefined);
28
- });
29
- test("parseArgs: an unknown flag is ignored, not fatal", () => {
30
- const a = parseArgs(["init", "--some-future-flag", "--print"]);
31
- assert.equal(a.cmd, "init");
32
- assert.equal(a.print, true);
33
- });
package/dist/i18n.test.js DELETED
@@ -1,78 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { allLocales, humanCodeForBoot, humanMessage, installerCodes, messageCodes, messagesForTest, resolveLocale, } from "./i18n.js";
4
- test("every error code has a non-empty message in all three mandated locales", () => {
5
- const locales = allLocales();
6
- assert.deepEqual([...locales], ["en-US", "pt-BR", "es-419"]);
7
- const dict = messagesForTest();
8
- for (const code of messageCodes()) {
9
- for (const loc of locales) {
10
- const msg = dict[code][loc];
11
- assert.equal(typeof msg, "string");
12
- assert.ok(msg.trim().length > 0, `empty ${loc} message for ${code}`);
13
- }
14
- }
15
- });
16
- test("locale resolution: POMBUS_LOCALE wins, then LANG, else en-US", () => {
17
- assert.equal(resolveLocale({ POMBUS_LOCALE: "pt-BR" }), "pt-BR");
18
- assert.equal(resolveLocale({ POMBUS_LOCALE: "es-ar" }), "es-419");
19
- assert.equal(resolveLocale({ LANG: "pt_BR.UTF-8" }), "pt-BR");
20
- assert.equal(resolveLocale({ LANG: "es_MX" }), "es-419");
21
- assert.equal(resolveLocale({ LANG: "en_US.UTF-8" }), "en-US");
22
- assert.equal(resolveLocale({}), "en-US");
23
- assert.equal(resolveLocale({ POMBUS_LOCALE: "pt", LANG: "en_US" }), "pt-BR"); // POMBUS_LOCALE wins
24
- });
25
- test("an unknown code falls back to the localized 'unknown' message, never a missing string", () => {
26
- for (const loc of allLocales()) {
27
- const msg = humanMessage("no_such_code_42", loc);
28
- assert.equal(msg, messagesForTest()["unknown"][loc]);
29
- assert.ok(msg.length > 0);
30
- }
31
- });
32
- test("the three locales are actually distinct for a representative message (not copy-paste English)", () => {
33
- const en = humanMessage("no_api_key", "en-US");
34
- const pt = humanMessage("no_api_key", "pt-BR");
35
- const es = humanMessage("no_api_key", "es-419");
36
- assert.notEqual(en, pt);
37
- assert.notEqual(en, es);
38
- assert.notEqual(pt, es);
39
- });
40
- test("the boot-error bridge maps CoreError codes to the human message code", () => {
41
- // The only two startup CoreError shapes a human can hit: an unreachable core, or a rejected key
42
- // (every 401/403/404 at /v0/auth/me collapses to not_found, which at boot means "bad key").
43
- assert.equal(humanCodeForBoot("network"), "network");
44
- assert.equal(humanCodeForBoot("not_found"), "auth_failed");
45
- // Anything unexpected -> the generic fallback, never a missing/raw code.
46
- assert.equal(humanCodeForBoot("core_error"), "unknown");
47
- assert.equal(humanCodeForBoot("bad_response"), "unknown");
48
- });
49
- test("NO human dictionary entry is orphaned — every code is reachable by a real code path", () => {
50
- // The guard the judge asked for: a translated string nobody can display is dictionary theater.
51
- // A human message code is reachable through exactly two human paths:
52
- // (1) the server boot path: no_api_key (emitted directly when the key env var is unset) + whatever
53
- // humanCodeForBoot can return (the ONLY bridge from a thrown boot error to a human code —
54
- // server.ts boot catch + bin.ts last-resort catch both go through it); OR
55
- // (2) the installer path: the codes init.ts/doctor.ts reference, enumerated by installerCodes().
56
- // We derive the reachable set mechanically from both, so a future dict entry with no caller fails
57
- // this test, and an installer code with no dict entry fails it too.
58
- const coreErrorCodes = ["network", "not_found", "bad_response", "core_error"];
59
- const reachable = new Set(["no_api_key"]);
60
- for (const c of coreErrorCodes)
61
- reachable.add(humanCodeForBoot(c));
62
- for (const c of installerCodes())
63
- reachable.add(c);
64
- const declared = new Set(messageCodes());
65
- assert.deepEqual([...declared].sort(), [...reachable].sort(), "dictionary must contain exactly the human-reachable codes — no orphans, no missing");
66
- });
67
- test("every installer code has a real dictionary entry (not just a listed name)", () => {
68
- // installerCodes() is the guard's source of truth for path (2). Prove each one resolves to a real,
69
- // non-empty, non-fallback string in all three locales — a name in the list with no MESSAGES entry
70
- // would otherwise pass the union test only because humanMessage falls back to `unknown`.
71
- const dict = messagesForTest();
72
- for (const code of installerCodes()) {
73
- assert.ok(dict[code], `installer code ${code} has no dictionary entry`);
74
- for (const loc of allLocales()) {
75
- assert.ok((dict[code][loc] ?? "").trim().length > 0, `empty ${loc} for ${code}`);
76
- }
77
- }
78
- });