diff --git a/__tests__/inbox-watch-plugin.test.js b/__tests__/inbox-watch-plugin.test.js new file mode 100644 index 000000000..a5aba4a6f --- /dev/null +++ b/__tests__/inbox-watch-plugin.test.js @@ -0,0 +1,86 @@ +const fs = require("fs") +const path = require("path") +const { runNoServer, makePluginEnv, withInstalledPlugin } = require("./helpers/plugin-test-env") + +// The real tool follows cli-output-spec: versioned JSON on stdout, typed errors +// on stderr, and a semantic exit code -- 10 meaning "new items", which is a +// SUCCESS signal a cron acts on, not a failure. The fake reproduces that shape. +function writeFakeInboxWatchBinary(dir) { + const bin = path.join(dir, "inbox-watch") + fs.writeFileSync(bin, [ + "#!/usr/bin/env node", + "const args = process.argv.slice(2);", + "if (args[0] === 'help-json') {", + " console.log(JSON.stringify({ version: '1.0.0', output: 'json', interactive: false,", + " commands: { 'help-json': { args: [] } },", + " exit_codes: { '0': 'success — nothing new', '10': 'success — new items found (the cron signal)' },", + " env: ['INBOX_TOKEN'] })); process.exit(0);", + "}", + "if (args[0] === 'guide') {", + " console.log(JSON.stringify({ version: '1.0.0', summary: 'tells you only when a human replied',", + " gotchas: ['The cursor is consumed on read: use --peek to diagnose.'] })); process.exit(0);", + "}", + "// --peek reports WITHOUT consuming; a bare run consumes and exits 10 when new.", + "const peek = args.includes('--peek');", + "const exitZero = args.includes('--exit-zero');", + "const item = { channel: 'mailbox', kind: 'reply', from: 'someone@example.com', title: 'Re: hello', url: '' };", + "console.log(JSON.stringify({ ok: true, version: '1.0.0', new: [item], count: 1,", + " disabled: [{ channel: 'resend', reason: 'set RESEND_API_KEY', recoverable: true }] }));", + "process.exit(exitZero ? 0 : 10);" + ].join("\n"), "utf-8") + fs.chmodSync(bin, 0o755) + return bin +} + +describe("inbox-watch plugin", () => { + const ctx = makePluginEnv("inbox-watch") + writeFakeInboxWatchBinary(ctx.fakeDir) + withInstalledPlugin(ctx) + + test("exposes the cli-output-spec command catalog", () => { + const r = runNoServer("inbox-watch self help-json --json", { env: ctx.env }) + expect(r.ok).toBe(true) + const data = JSON.parse(r.output) + expect(data.command).toBe("inbox-watch.self.help-json") + expect(data.data.version).toBe("1.0.0") + // exit code 10 must be documented as a success signal, not an error + expect(data.data.exit_codes["10"]).toMatch(/success/) + }) + + test("routes the embedded guide", () => { + const r = runNoServer("inbox-watch self guide --json", { env: ctx.env }) + expect(r.ok).toBe(true) + const data = JSON.parse(r.output) + expect(data.command).toBe("inbox-watch.self.guide") + expect(data.data.gotchas.join(" ")).toMatch(/--peek/) + }) + + test("returns the versioned envelope with classified items", () => { + const r = runNoServer("inbox-watch inbox check --json", { env: ctx.env }) + expect(r.ok).toBe(true) + const data = JSON.parse(r.output) + expect(data.command).toBe("inbox-watch.inbox.check") + expect(data.data.ok).toBe(true) + expect(data.data.new[0].kind).toBe("reply") + // a disabled channel is structured context, not prose + expect(data.data.disabled[0]).toHaveProperty("recoverable") + }) + + test("exit 10 (new items) is a SUCCESS, not a failure", () => { + // supercli's process adapter treats any non-zero exit as failure, and + // inbox-watch exits 10 when mail arrived -- so routed naively, the single + // most important case would surface as an error. The plugin normalises it. + const r = runNoServer("inbox-watch inbox check --json", { env: ctx.env }) + expect(r.ok).toBe(true) + expect(JSON.parse(r.output).data.count).toBe(1) + }) + + test("peek is a distinct command from check", () => { + // They are separate on purpose: the destructive one must never be the one + // reached for while debugging, since a normal run consumes the cursor and + // eats the alert the cron would have sent. + const r = runNoServer("inbox-watch inbox peek --json", { env: ctx.env }) + expect(r.ok).toBe(true) + expect(JSON.parse(r.output).command).toBe("inbox-watch.inbox.peek") + }) +}) diff --git a/plugins/inbox-watch/install-guidance.json b/plugins/inbox-watch/install-guidance.json new file mode 100644 index 000000000..e534dc4a7 --- /dev/null +++ b/plugins/inbox-watch/install-guidance.json @@ -0,0 +1,12 @@ +{ + "plugin": "inbox-watch", + "binary": "inbox-watch", + "check": "which inbox-watch", + "install_steps": [ + "git clone https://github.com/javimosch/inbox-watch /tmp/inbox-watch", + "install -m755 /tmp/inbox-watch/inbox-watch ~/.local/bin/inbox-watch", + "mkdir -p ~/.inbox-watch && cp /tmp/inbox-watch/config.example.json ~/.inbox-watch/config.json", + "Verify: inbox-watch help-json" + ], + "note": "Single Python 3 file, no dependencies. No auth required by the tool itself; each channel needs its own secret in an env var." +} diff --git a/plugins/inbox-watch/meta.json b/plugins/inbox-watch/meta.json new file mode 100644 index 000000000..ef7acaf4b --- /dev/null +++ b/plugins/inbox-watch/meta.json @@ -0,0 +1,5 @@ +{ + "description": "Poll GitHub notifications, IMAP and Resend inbound for items new since the last run. Classifies DMARC reports, out-of-office autoreplies and real human replies so alerts stay worth reading. Exit 10 when something arrived — cron-shaped. Agent-first: versioned JSON on stdout, typed errors on stderr, semantic exit codes, help-json and guide.", + "tags": ["inbox-watch", "cli", "email", "notifications", "monitoring", "agent-first", "cron"], + "has_learn": true +} diff --git a/plugins/inbox-watch/plugin.json b/plugins/inbox-watch/plugin.json new file mode 100644 index 000000000..46aa9796e --- /dev/null +++ b/plugins/inbox-watch/plugin.json @@ -0,0 +1,124 @@ +{ + "name": "inbox-watch", + "version": "1.0.0", + "description": "Tell me only when a human actually replied \u2014 poll GitHub/IMAP/Resend inbound, classify DMARC vs autoreply vs reply, exit 10 when something real arrived", + "source": "https://github.com/javimosch/inbox-watch", + "checks": [ + { + "type": "binary", + "name": "inbox-watch" + } + ], + "install_guidance": { + "plugin": "inbox-watch", + "binary": "inbox-watch", + "check": "which inbox-watch", + "install_steps": [ + "git clone https://github.com/javimosch/inbox-watch /tmp/inbox-watch", + "install -m755 /tmp/inbox-watch/inbox-watch ~/.local/bin/inbox-watch", + "mkdir -p ~/.inbox-watch && cp /tmp/inbox-watch/config.example.json ~/.inbox-watch/config.json", + "Edit ~/.inbox-watch/config.json (what to watch); secrets go in env vars", + "Verify: inbox-watch help-json", + "supercli plugins install ./plugins/inbox-watch --on-conflict replace --json" + ], + "note": "Single Python 3 file, no dependencies. Config holds what to watch; env vars hold every secret (INBOX_TOKEN / RESEND_API_KEY / ZOHO_IMAP_PASS)." + }, + "learn": { + "file": "skills/quickstart/SKILL.md" + }, + "commands": [ + { + "namespace": "inbox-watch", + "resource": "inbox", + "action": "check", + "description": "Poll every configured channel and print items new since the last run. Exit 10 if any. CONSUMES the cursor \u2014 use inbox_peek to diagnose.", + "adapter": "process", + "adapterConfig": { + "command": "inbox-watch", + "baseArgs": [ + "--exit-zero" + ], + "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch", + "note": "--exit-zero because a non-zero exit is treated as failure by the process adapter, and inbox-watch's exit 10 means 'new items' \u2014 a success. Read data.count instead." + }, + "args": [] + }, + { + "namespace": "inbox-watch", + "resource": "inbox", + "action": "peek", + "description": "Same report WITHOUT marking items seen. Use this for any diagnostic run: a normal run consumes the alert your cron would have sent.", + "adapter": "process", + "adapterConfig": { + "command": "inbox-watch", + "baseArgs": [ + "--peek", + "--exit-zero" + ], + "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch", + "note": "--exit-zero because a non-zero exit is treated as failure by the process adapter, and inbox-watch's exit 10 means 'new items' \u2014 a success. Read data.count instead." + }, + "args": [] + }, + { + "namespace": "inbox-watch", + "resource": "inbox", + "action": "seed", + "description": "Mark everything currently pending as seen, reporting nothing. Run once when adding a new consumer, or it alerts on the whole backlog.", + "adapter": "process", + "adapterConfig": { + "command": "inbox-watch", + "baseArgs": [ + "--seed", + "--exit-zero" + ], + "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch", + "note": "--exit-zero because a non-zero exit is treated as failure by the process adapter, and inbox-watch's exit 10 means 'new items' \u2014 a success. Read data.count instead." + }, + "args": [] + }, + { + "namespace": "inbox-watch", + "resource": "self", + "action": "guide", + "description": "Embedded operator manual: the model, the loop, and the gotchas", + "adapter": "process", + "adapterConfig": { + "command": "inbox-watch", + "baseArgs": [ + "guide" + ], + "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch" + }, + "args": [] + }, + { + "namespace": "inbox-watch", + "resource": "self", + "action": "help-json", + "description": "Machine-readable command catalog (cli-output-spec)", + "adapter": "process", + "adapterConfig": { + "command": "inbox-watch", + "baseArgs": [ + "help-json" + ], + "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch" + }, + "args": [] + }, + { + "namespace": "inbox-watch", + "resource": "_", + "action": "_", + "description": "Passthrough to the inbox-watch CLI", + "adapter": "process", + "adapterConfig": { + "command": "inbox-watch", + "passthrough": true, + "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch" + }, + "args": [] + } + ] +} \ No newline at end of file diff --git a/plugins/inbox-watch/skills/quickstart/SKILL.md b/plugins/inbox-watch/skills/quickstart/SKILL.md new file mode 100644 index 000000000..28d4af47d --- /dev/null +++ b/plugins/inbox-watch/skills/quickstart/SKILL.md @@ -0,0 +1,96 @@ +--- +name: inbox-watch-quickstart +description: Poll GitHub, IMAP and Resend inbound for genuinely new items and alert only when a human actually replied. Use when setting up reply monitoring, wiring a cron/timer to an inbox, or debugging why an expected alert never fired. +--- + +# inbox-watch + +**Tells you only when a human actually replied.** Polls GitHub notifications, IMAP +and Resend inbound; prints what is new since the last run; **exits 10** if there +was anything — so a cron alerts only when something real arrived. + +## The one idea + +Inbound is mostly machine noise. On a domain with DMARC reporting on, aggregate +reports outnumber real mail several to one. A notifier that pings for all of it +gets muted — **and then the reply that mattered is muted with it.** So every item +is classified and only one kind is a person: + +| kind | alert? | +|---|---| +| `dmarc` | no — counted, never pinged alone | +| `autoreply` | yes, but **labelled** so it doesn't read as a reply | +| `reply` | **yes** — this is the product | + +## Use + +```sh +inbox-watch # JSON on stdout, exit 10 if new +inbox-watch --human # readable lines +inbox-watch guide # embedded operator manual +inbox-watch help-json # command catalog +inbox-watch setup # what each channel needs +``` + +Output follows [cli-output-spec](https://cli-specs.intrane.fr/): stdout is +versioned data, stderr is context and typed errors, exit code is the signal. + +```json +{ "ok": true, "version": "1.0.0", "count": 1, + "new": [ { "channel": "mailbox", "kind": "reply", "from": "…", "title": "…" } ], + "disabled": [ { "channel": "resend", "reason": "set RESEND_API_KEY …", "recoverable": true } ] } +``` + +| exit | meaning | +|---:|---| +| 0 | nothing new | +| **10** | **new items — the cron signal** | +| 80 / 90 / 100 / 110 | input / precondition / external / internal | + +## Two traps — read these before debugging + +Both were found in production, and both make a *working* system look broken (or +a broken one look fine). + +**1. The cursor is consumed on read.** Any normal run marks items seen. So +running `inbox-watch` by hand to "check if it works" **eats the alert the cron +would have sent** — and the cron then reports nothing, which looks like success. + +> Use `--peek` for anything diagnostic. It reports identically without +> committing state. + +**2. Two watchers on one host steal from each other.** Same cause: they share a +cursor, so whichever polls first wins and the other's alert never fires. + +> Give each its own: `--consumer NAME`. Run `--seed` once for a new consumer, or +> its first run alerts on the entire backlog. + +```sh +inbox-watch --peek --human # safe diagnosis +inbox-watch --consumer laptop --seed # add a second watcher, no backlog flood +inbox-watch --consumer laptop +``` + +## Configure + +`~/.inbox-watch/config.json` holds **what to watch**; env vars hold **every +secret**. Start from `config.example.json`. Unconfigured channels report why +they are disabled rather than failing, so a fresh install runs and tells you +what it needs. + +| channel | secret | note | +|---|---|---| +| `mailbox` | `INBOX_TOKEN` + `mailbox.url` | token-gated `GET /api/inbound`. **Prefer this over `resend`** — a read-only token cannot send mail as you | +| `resend` | `RESEND_API_KEY` | note this key *can also send* | +| `imap` | `ZOHO_IMAP_PASS` + `imap.user` | any IMAP host, app-specific password | +| `github` | authenticated `gh` | mentions/review-requests, plus specific issue/PR threads you name | + +## Wire it up + +``` +*/15 * * * * inbox-watch --human | grep . && +``` + +`run.sh.template` in the repo is a worked example (Telegram). It **logs the +delivery result** — a send that fails silently is the same failure mode this +tool exists to prevent, so don't discard it.