Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
12 changes: 8 additions & 4 deletions bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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 }));
}
});

Expand Down
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
34 changes: 34 additions & 0 deletions test/bridge-security.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading