diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8b3a8..5a55be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- The bridge now names every forwarded failure: a request the extension didn't answer in time returns HTTP 504 `TIMEOUT` (it used to be a 500 with no code, which clients reported as `THUNDERBIRD_ERROR`), a missing extension returns 503 `EXTENSION_DISCONNECTED`, and an extension error without its own code returns 500 `THUNDERBIRD_ERROR`. Agents can now tell "timed out, outcome unknown" apart from a real Thunderbird error. Idea from vitalio-sh/thunderbird-cli#26 / dd95a6a, reimplemented. + ## [1.3.0] — 2026-09-28 Needs add-on 2.4.0 for the calendar, task and Fast Actions commands. Until ATN finishes its manual review of 2.4.0 (required for Experiment APIs), this release attaches the signed 2.1.0 XPI plus an unsigned 2.4.0 build. The email, contacts and notes commands work with either. diff --git a/SPEC.md b/SPEC.md index 8f6ab2c..d107a23 100644 --- a/SPEC.md +++ b/SPEC.md @@ -824,13 +824,13 @@ covered by it. | Code | Meaning | |------|---------| | `BRIDGE_UNREACHABLE` | Bridge is not running | -| `EXTENSION_DISCONNECTED` | Thunderbird extension not connected to bridge | +| `EXTENSION_DISCONNECTED` | Thunderbird extension not connected to bridge (HTTP 503) | | `AUTH_REQUIRED` | Bridge requires `TB_AUTH_TOKEN`; the request had none or the wrong one | | `FORBIDDEN` | Bridge refused a browser `Origin` or non-local `Host` header (see `TB_BRIDGE_CORS_ORIGINS`, `TB_BRIDGE_ALLOWED_HOSTS`) | -| `TIMEOUT` | Request to extension timed out (30s) | +| `TIMEOUT` | Request to extension timed out (HTTP 504; default 120s, `TB_BRIDGE_TIMEOUT` / `X-TB-Timeout`). The outcome is unknown: the extension may still complete it | | `NOT_FOUND` | Message/folder/account not found | | `INVALID_ARGS` | Bad CLI arguments | -| `THUNDERBIRD_ERROR` | Error from Thunderbird messenger API | +| `THUNDERBIRD_ERROR` | Error from Thunderbird messenger API (HTTP 500; the bridge's default when the extension gives no code) | | `EVENT_TIMEOUT` | No matching bridge event arrived before the wait timeout | | `RECONNECT_TIMEOUT` | Extension did not reconnect after `tb extension-reload` | diff --git a/bridge/bridge.js b/bridge/bridge.js index 725fc1f..caffe75 100755 --- a/bridge/bridge.js +++ b/bridge/bridge.js @@ -228,13 +228,15 @@ export async function startBridge(opts = {}) { function forwardToExtension(method, path, body, timeoutMs = DEFAULT_TIMEOUT) { return new Promise((resolve, reject) => { if (!extensionSocket || extensionSocket.readyState !== 1) { - reject({ message: "Thunderbird extension not connected. Is Thunderbird running?" }); + reject({ message: "Thunderbird extension not connected. Is Thunderbird running?", code: "EXTENSION_DISCONNECTED" }); return; } const id = randomUUID(); + // A timeout means the outcome is unknown (the extension may still finish the request), so + // it gets its own code rather than looking like a Thunderbird error or an empty result. const timer = setTimeout(() => { pending.delete(id); - reject({ message: `Request timed out (${Math.round(timeoutMs / 1000)}s)` }); + reject({ message: `Request timed out (${Math.round(timeoutMs / 1000)}s)`, code: "TIMEOUT" }); }, timeoutMs); pending.set(id, { resolve, reject, timer }); extensionSocket.send(JSON.stringify({ id, method, path, body })); @@ -383,9 +385,11 @@ export async function startBridge(opts = {}) { res.writeHead(200); res.end(JSON.stringify(result)); } catch (err) { - const status = err.code === "FORBIDDEN" ? 403 : err.message?.includes("not connected") ? 503 : 500; + // Always name the failure so clients don't have to guess from the status code. + const code = err.code || "THUNDERBIRD_ERROR"; + const status = { FORBIDDEN: 403, EXTENSION_DISCONNECTED: 503, TIMEOUT: 504 }[code] || 500; res.writeHead(status); - res.end(JSON.stringify({ error: err.message || "Unknown error", code: err.code })); + res.end(JSON.stringify({ error: err.message || "Unknown error", code })); } }); diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index fac4b71..4318ed1 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -434,7 +434,7 @@ Config file: `~/.config/thunderbird-cli/config.json` |------|---------| | `BRIDGE_UNREACHABLE` | Bridge is not running | | `EXTENSION_DISCONNECTED` | Thunderbird extension not connected | -| `TIMEOUT` | Request timed out (30s default) | +| `TIMEOUT` | Request timed out (120s default); the outcome is unknown, not empty | | `NOT_FOUND` | Message/folder/account not found | | `INVALID_ARGS` | Bad arguments or missing `--confirm` | | `THUNDERBIRD_ERROR` | Error from Thunderbird messenger API | diff --git a/test/bridge-security.mjs b/test/bridge-security.mjs index e1eb3c1..c29ea69 100644 --- a/test/bridge-security.mjs +++ b/test/bridge-security.mjs @@ -190,6 +190,40 @@ await withBridge({ TB_BRIDGE_WS_HEARTBEAT_MS: "150" }, async () => { test("unresponsive socket is terminated", await extensionStatus(), "disconnected"); }); +// ─── Error codes ──────────────────────────────────────────────────── + +console.log("\n\x1b[1mError codes\x1b[0m"); +await withBridge({}, async () => { + const none = await http("POST", "/messages/search", { body: "{}" }); + test("no extension → 503", none.status, 503); + test("no extension → EXTENSION_DISCONNECTED", none.json?.code, "EXTENSION_DISCONNECTED"); + + // A fake extension that never answers, so the bridge's own timer fires. + const silent = new WebSocket(`ws://127.0.0.1:${WS_PORT}`); + await new Promise((r) => silent.on("open", r)); + const slow = await http("POST", "/messages/search", { headers: { "X-TB-Timeout": "200" }, body: "{}" }); + test("extension timeout → 504", slow.status, 504); + test("extension timeout → TIMEOUT", slow.json?.code, "TIMEOUT"); + silent.close(); + await sleep(100); + + // A fake extension that fails every request, with and without its own code. + const failing = new WebSocket(`ws://127.0.0.1:${WS_PORT}`); + failing.on("message", (d) => { + const msg = JSON.parse(d.toString()); + const error = msg.path === "/denied" ? { message: "denied", code: "FORBIDDEN" } : { message: "boom" }; + failing.send(JSON.stringify({ id: msg.id, error })); + }); + await new Promise((r) => failing.on("open", r)); + const boom = await http("POST", "/messages/search", { body: "{}" }); + test("uncoded extension error → 500", boom.status, 500); + test("uncoded extension error → THUNDERBIRD_ERROR", boom.json?.code, "THUNDERBIRD_ERROR"); + const denied = await http("GET", "/denied"); + test("extension FORBIDDEN → 403", denied.status, 403); + test("extension FORBIDDEN code kept", denied.json?.code, "FORBIDDEN"); + failing.close(); +}); + // ─── Startup: port already taken ──────────────────────────────────── console.log("\n\x1b[1mPort in use\x1b[0m");