proton-mail-bridge-client 2.3.1 → 2.4.0

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.
Files changed (73) hide show
  1. package/README.md +8 -2
  2. package/dist/cli.d.ts +11 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +348 -202
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +10 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +304 -143
  9. package/dist/index.js.map +1 -1
  10. package/dist/scripts/check-claude-desktop.d.ts +18 -0
  11. package/dist/scripts/check-claude-desktop.d.ts.map +1 -1
  12. package/dist/scripts/check-claude-desktop.js +91 -30
  13. package/dist/scripts/check-claude-desktop.js.map +1 -1
  14. package/dist/scripts/install-claude-desktop.d.ts +12 -0
  15. package/dist/scripts/install-claude-desktop.d.ts.map +1 -1
  16. package/dist/scripts/install-claude-desktop.js +211 -43
  17. package/dist/scripts/install-claude-desktop.js.map +1 -1
  18. package/dist/services/analytics-service.d.ts +1 -1
  19. package/dist/services/analytics-service.d.ts.map +1 -1
  20. package/dist/services/analytics-service.js +2 -2
  21. package/dist/services/analytics-service.js.map +1 -1
  22. package/dist/services/audit-service.d.ts.map +1 -1
  23. package/dist/services/audit-service.js +7 -2
  24. package/dist/services/audit-service.js.map +1 -1
  25. package/dist/services/background-sync-service.d.ts.map +1 -1
  26. package/dist/services/background-sync-service.js +10 -2
  27. package/dist/services/background-sync-service.js.map +1 -1
  28. package/dist/services/delivery-queue-service.d.ts +4 -1
  29. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  30. package/dist/services/delivery-queue-service.js +21 -27
  31. package/dist/services/delivery-queue-service.js.map +1 -1
  32. package/dist/services/draft-store-service.d.ts.map +1 -1
  33. package/dist/services/draft-store-service.js +13 -25
  34. package/dist/services/draft-store-service.js.map +1 -1
  35. package/dist/services/local-index-service.d.ts +2 -1
  36. package/dist/services/local-index-service.d.ts.map +1 -1
  37. package/dist/services/local-index-service.js +91 -33
  38. package/dist/services/local-index-service.js.map +1 -1
  39. package/dist/services/simple-imap-service.d.ts +22 -2
  40. package/dist/services/simple-imap-service.d.ts.map +1 -1
  41. package/dist/services/simple-imap-service.js +295 -107
  42. package/dist/services/simple-imap-service.js.map +1 -1
  43. package/dist/services/smtp-service.d.ts.map +1 -1
  44. package/dist/services/smtp-service.js +24 -2
  45. package/dist/services/smtp-service.js.map +1 -1
  46. package/dist/services/snooze-service.d.ts.map +1 -1
  47. package/dist/services/snooze-service.js +43 -30
  48. package/dist/services/snooze-service.js.map +1 -1
  49. package/dist/services/template-service.d.ts +4 -2
  50. package/dist/services/template-service.d.ts.map +1 -1
  51. package/dist/services/template-service.js +46 -20
  52. package/dist/services/template-service.js.map +1 -1
  53. package/dist/types/index.d.ts +1 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/dist/utils/atomic-write.d.ts +2 -0
  56. package/dist/utils/atomic-write.d.ts.map +1 -0
  57. package/dist/utils/atomic-write.js +36 -0
  58. package/dist/utils/atomic-write.js.map +1 -0
  59. package/dist/utils/corrupt-store.d.ts +4 -0
  60. package/dist/utils/corrupt-store.d.ts.map +1 -0
  61. package/dist/utils/corrupt-store.js +29 -0
  62. package/dist/utils/corrupt-store.js.map +1 -0
  63. package/dist/utils/file-lock.d.ts.map +1 -1
  64. package/dist/utils/file-lock.js +39 -4
  65. package/dist/utils/file-lock.js.map +1 -1
  66. package/dist/utils/helpers.d.ts +14 -0
  67. package/dist/utils/helpers.d.ts.map +1 -1
  68. package/dist/utils/helpers.js +281 -27
  69. package/dist/utils/helpers.js.map +1 -1
  70. package/dist/utils/logger.d.ts.map +1 -1
  71. package/dist/utils/logger.js +29 -12
  72. package/dist/utils/logger.js.map +1 -1
  73. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -29,27 +29,60 @@ async function getPkgVersion() {
29
29
  // every message instead of the one intended. Listing them here makes such a flag always
30
30
  // boolean regardless of what follows it, so the next token is correctly left as a positional.
31
31
  const BOOLEAN_FLAGS = new Set([
32
- "all", "confirmed", "dry-run", "full", "html", "json", "live", "no-attachment-text",
33
- "permanent", "read", "reply-all", "sent", "starred", "sync", "unread", "unread-only",
34
- "unstar", "unstarred", "wait",
32
+ "all", "checkConnections", "confirmed", "dry-run", "full", "help", "html", "json", "live",
33
+ "no-attachment-text", "permanent", "read", "reply-all", "sent", "starred", "sync", "unread",
34
+ "unread-only", "unstar", "unstarred", "version", "wait",
35
35
  ]);
36
+ // A command-line mistake (unknown flag, malformed number, repeated flag, missing filter):
37
+ // reported on stderr and exits 2, as opposed to a runtime failure, which exits 1.
38
+ export class CliUsageError extends Error {
39
+ }
40
+ // Option syntax: `--flag value`, `--flag=value` (split at the FIRST `=`, so the value may
41
+ // contain more), and `--` ends option parsing (everything after it is positional). A bare
42
+ // `--flag` followed by another `--token` is a boolean flag; a value that itself starts with
43
+ // `--` therefore has to use the `=` form or come after `--`. An empty string is a value.
36
44
  export function parseCliArgs(argv) {
37
45
  const positionals = [];
38
46
  const flags = {};
47
+ let optionsEnded = false;
39
48
  for (let index = 0; index < argv.length; index += 1) {
40
49
  const token = argv[index];
41
- if (!token.startsWith("--")) {
50
+ if (optionsEnded) {
42
51
  positionals.push(token);
43
52
  continue;
44
53
  }
45
- const key = token.slice(2);
46
- const next = argv[index + 1];
47
- if (BOOLEAN_FLAGS.has(key) || !next || next.startsWith("--")) {
48
- flags[key] = true;
54
+ if (token === "--") {
55
+ optionsEnded = true;
56
+ continue;
57
+ }
58
+ if (token === "-h" || token === "-v") {
59
+ flags[token === "-h" ? "help" : "version"] = true;
60
+ continue;
61
+ }
62
+ if (!token.startsWith("--")) {
63
+ positionals.push(token);
49
64
  continue;
50
65
  }
51
- flags[key] = next;
52
- index += 1;
66
+ const equals = token.indexOf("=");
67
+ const key = equals === -1 ? token.slice(2) : token.slice(2, equals);
68
+ let value;
69
+ if (equals !== -1) {
70
+ value = token.slice(equals + 1);
71
+ }
72
+ else {
73
+ const next = argv[index + 1];
74
+ if (BOOLEAN_FLAGS.has(key) || next === undefined || next.startsWith("--")) {
75
+ value = true;
76
+ }
77
+ else {
78
+ value = next;
79
+ index += 1;
80
+ }
81
+ }
82
+ if (Object.hasOwn(flags, key) && !BOOLEAN_FLAGS.has(key)) {
83
+ throw new CliUsageError(`--${key} was given more than once; pass it a single time.`);
84
+ }
85
+ flags[key] = value;
53
86
  }
54
87
  const command = positionals[0] || "help";
55
88
  const subcommand = command === "claude" ? positionals[1] : undefined;
@@ -151,6 +184,12 @@ function printHelp() {
151
184
  "Global flags:",
152
185
  " --version, -v Print version and exit",
153
186
  " --json Print machine-readable JSON",
187
+ " --help, -h Show help for a command (never runs it): proton-mail-bridge send --help",
188
+ " --flag=value Same as --flag value; required when the value starts with --",
189
+ " -- End of options: everything after it is a plain argument",
190
+ "",
191
+ "Exit codes: 0 success, 1 failure (including a failed doctor/connection check or a failed",
192
+ " item in batch/bulk runs), 2 usage error (unknown flag, bad number, missing bulk filter).",
154
193
  "",
155
194
  "Search flags:",
156
195
  " --folder <name> Limit to one folder",
@@ -182,6 +221,122 @@ function printHelp() {
182
221
  " proton-mail-bridge claude check",
183
222
  ].join("\n"));
184
223
  }
224
+ const BODY_FLAGS = ["body"];
225
+ const SEARCH_FLAGS = ["folder", "limit", "live", "sync", "label", "from", "to", "subject", "domain", "dateFrom", "dateTo", "read", "unread", "starred", "unstarred"];
226
+ const DRAFT_COPY_FLAGS = ["cc", "bcc", "notes"];
227
+ const BULK_FILTER_FLAGS = ["from", "subject", "since", "before"];
228
+ export const COMMAND_SPECS = {
229
+ help: { usage: "help [command]", description: "Show the command list, or the help for one command", flags: [] },
230
+ version: { usage: "version", description: "Print the version and exit", flags: [] },
231
+ "setup-claude-desktop": { usage: "setup-claude-desktop", description: "Run the Claude Desktop setup wizard (works from any install)", flags: [] },
232
+ claude: { usage: "claude <setup|install|check|update|doctor>", description: "Claude Desktop integration: setup wizard, install or update the runtime, check status (update is an alias for install, doctor for check)", flags: [] },
233
+ status: { usage: "status", description: "Show local config, index, runtime, and Claude Desktop status", flags: [] },
234
+ doctor: { usage: "doctor", description: "Verify IMAP, SMTP, and Claude Desktop wiring (exits 1 when the check fails)", flags: [] },
235
+ "connection-status": { usage: "connection-status", description: "Show live IMAP/SMTP connectivity state (exits 1 when either is unreachable)", flags: [] },
236
+ "runtime-status": { usage: "runtime-status", description: "Show runtime policy and background sync state", flags: [] },
237
+ sync: { usage: "sync [--folder <name>] [--limit <n>] [--full]", description: "Refresh the local index from Proton Bridge", flags: ["folder", "limit", "full", "no-attachment-text"] },
238
+ "index-status": { usage: "index-status", description: "Show local index health and freshness", flags: [] },
239
+ folders: { usage: "folders", description: "List available folders from Proton Bridge", flags: [] },
240
+ "create-folder": { usage: "create-folder <path>", description: "Create a mailbox folder (e.g. Folders/Receipts)", flags: ["path"] },
241
+ "rename-folder": { usage: "rename-folder <path> <newPath>", description: "Rename a folder (or use --to <newPath>)", flags: ["path", "to", "new-path"] },
242
+ "delete-folder": { usage: "delete-folder <path> [--confirmed]", description: "Delete an empty folder", flags: ["path", "confirmed"] },
243
+ "empty-folder": { usage: "empty-folder <folder> [--confirmed]", description: "Permanently empty a folder (--confirmed to execute)", flags: ["folder", "confirmed"] },
244
+ labels: { usage: "labels [--limit <n>]", description: "List normalized labels from the local index", flags: ["limit"] },
245
+ threads: { usage: "threads [query] [--label <name>] [--limit <n>] [--sync]", description: "List normalized threads from the local index", flags: ["sync", "folder", "limit", "label"] },
246
+ digest: { usage: "digest [--limit <n>] [--age-hours <n>] [--sync]", description: "Show inbox digest and top actionable threads", flags: ["sync", "limit", "age-hours"] },
247
+ followups: { usage: "followups [--pending you|them|any] [--limit <n>] [--age-hours <n>] [--sync]", description: "Show follow-up candidates from the local index", flags: ["sync", "pending", "limit", "age-hours"] },
248
+ emails: { usage: "emails [--folder <name>] [--limit <n>] [--offset <n>]", description: "List emails from a folder", flags: ["folder", "limit", "offset"] },
249
+ attachments: { usage: "attachments <emailId>", description: "List attachments for one message", flags: [] },
250
+ search: { usage: "search [query] [--live] [--sync] [filters]", description: "Search indexed mail (default) or live mail with --live", flags: SEARCH_FLAGS },
251
+ read: { usage: "read <emailId>", description: "Read one email by composite email id", flags: [] },
252
+ move: { usage: "move <emailId> <folder>", description: "Move an email to another folder (target folder as second argument or --folder)", flags: ["folder"] },
253
+ archive: { usage: "archive <emailId>", description: "Archive an email", flags: [] },
254
+ trash: { usage: "trash <emailId>", description: "Move an email to Trash", flags: [] },
255
+ restore: { usage: "restore <emailId> [--folder <name>]", description: "Restore an email from Trash to Inbox (or --folder)", flags: ["folder"] },
256
+ "mark-read": { usage: "mark-read <emailId> [--unread]", description: "Mark read (--unread to flip)", flags: ["unread"] },
257
+ star: { usage: "star <emailId> [--unstar]", description: "Star an email (--unstar to flip)", flags: ["unstar"] },
258
+ delete: { usage: "delete <emailId> [--confirmed]", description: "Permanently delete an email", flags: ["confirmed"] },
259
+ batch: { usage: "batch <action> <emailId...> [--ids <a,b,c>] [--folder <name>] [--dry-run]", description: "Apply an action (mark_read|mark_unread|star|unstar|archive|trash|restore) to multiple emails; exits 1 if any item failed", flags: ["action", "ids", "folder", "dry-run"] },
260
+ "bulk-delete": { usage: "bulk-delete (--from <v> | --subject <v> | --since <date> | --before <date>)... [--folder <name>] [--dry-run] [--permanent] [--max <n>] [--confirmed]", description: "Delete emails matching the filters (moves to Trash unless --permanent). At least one filter is required; use --dry-run first", flags: [...BULK_FILTER_FLAGS, "folder", "dry-run", "permanent", "max", "confirmed"] },
261
+ "bulk-move": { usage: "bulk-move <folder> (--from <v> | --subject <v> | --since <date> | --before <date>)... [--folder <name>] [--dry-run] [--max <n>]", description: "Move emails matching the filters to a folder. At least one filter is required; use --dry-run first", flags: [...BULK_FILTER_FLAGS, "target-folder", "folder", "dry-run", "max"] },
262
+ send: { usage: "send --to <addr> --subject <text> (--body <text> | stdin) [--cc <a>] [--bcc <a>] [--html] [--dry-run] [--confirmed] [--undo-window <s>] [--wait]", description: "Send an email", flags: ["to", "cc", "bcc", "subject", ...BODY_FLAGS, "html", "dry-run", "confirmed", "undo-window", "wait"] },
263
+ reply: { usage: "reply <emailId> (--body <text> | stdin) [--reply-all] [--confirmed] [--undo-window <s>]", description: "Reply to an email", flags: [...BODY_FLAGS, "reply-all", "all", "confirmed", "undo-window"] },
264
+ forward: { usage: "forward <emailId> --to <addr> [--body <text> | stdin] [--confirmed] [--undo-window <s>]", description: "Forward an email", flags: ["to", ...BODY_FLAGS, "confirmed", "undo-window"] },
265
+ "test-email": { usage: "test-email <addr> [--message <text>] [--confirmed]", description: "Send a test email to verify SMTP (--confirmed if required)", flags: ["to", "message", "confirmed"] },
266
+ thread: { usage: "thread <threadId>", description: "Fetch a full thread by id", flags: ["id"] },
267
+ "thread-brief": { usage: "thread-brief <threadId>", description: "Summarise a thread (latest in/out, next action)", flags: ["id"] },
268
+ "thread-action": { usage: "thread-action <threadId> <action> [--folder <name>] [--unread-only] [--dry-run]", description: "Apply an action to all messages in a thread", flags: ["id", "action", "folder", "unread-only", "dry-run"] },
269
+ actionable: { usage: "actionable [--limit <n>]", description: "List actionable threads", flags: ["limit"] },
270
+ "document-threads": { usage: "document-threads [query] [--category <name>] [--limit <n>] [--sync]", description: "Find threads with important attachments", flags: ["category", "limit", "sync"] },
271
+ "meeting-context": { usage: "meeting-context <person> [--domain <d>] [--limit <n>] [--sync]", description: "Prep context for a meeting", flags: ["person", "domain", "limit", "sync"] },
272
+ stats: { usage: "stats", description: "Mailbox counts and analytics sample", flags: [] },
273
+ analytics: { usage: "analytics", description: "Detailed mailbox analytics (top senders, busy hours)", flags: [] },
274
+ "folder-stats": { usage: "folder-stats [folder]", description: "Live message stats for a folder", flags: ["folder"] },
275
+ contacts: { usage: "contacts [--limit <n>]", description: "Contacts ranked by interaction volume", flags: ["limit"] },
276
+ "volume-trends": { usage: "volume-trends [--days <n>]", description: "Daily message counts (default 30 days)", flags: ["days"] },
277
+ watch: { usage: "watch [--folder <name>] [--timeout <s>]", description: "Wait for mailbox changes via IMAP IDLE", flags: ["folder", "timeout"] },
278
+ "clear-cache": { usage: "clear-cache", description: "Clear in-memory MCP server caches", flags: [] },
279
+ "get-logs": { usage: "get-logs [--limit <n>] [--offset <n>] [--level <level>]", description: "Return recent in-memory MCP server logs", flags: ["limit", "level", "offset"] },
280
+ notify: { usage: "notify [--folder <name>] [--timeout <s>]", description: "Daemon: watch a folder and send a system notification on new mail", flags: ["folder", "timeout"] },
281
+ drafts: { usage: "drafts [--sent]", description: "List local drafts", flags: ["sent"] },
282
+ "remote-drafts": { usage: "remote-drafts [--limit <n>] [--offset <n>]", description: "List drafts in the Proton Drafts mailbox", flags: ["limit", "offset"] },
283
+ "draft-create": { usage: "draft-create --subject <text> [--to <addr>] (--body <text> | stdin) [--cc <a>] [--bcc <a>]", description: "Create a draft", flags: ["to", "cc", "bcc", "subject", ...BODY_FLAGS] },
284
+ "draft-read": { usage: "draft-read <id>", description: "Read a saved draft", flags: ["id"] },
285
+ "draft-update": { usage: "draft-update <id> [--subject <text>] [--body <text> | stdin] [--to <a>] [--cc <a>] [--bcc <a>] [--notes <text>]", description: "Update a draft", flags: ["id", "to", "cc", "bcc", "subject", ...BODY_FLAGS, "notes"] },
286
+ "draft-reply": { usage: "draft-reply <emailId> (--body <text> | stdin) [--reply-all]", description: "Create a reply draft", flags: ["id", ...BODY_FLAGS, "reply-all", "all", ...DRAFT_COPY_FLAGS] },
287
+ "draft-forward": { usage: "draft-forward <emailId> --to <addr> [--body <text> | stdin]", description: "Create a forward draft", flags: ["id", "to", ...BODY_FLAGS, ...DRAFT_COPY_FLAGS] },
288
+ "draft-sync": { usage: "draft-sync <id>", description: "Sync a local draft to the Proton Drafts mailbox", flags: ["id"] },
289
+ "draft-send": { usage: "draft-send <id> [--args '{...}']", description: "Send a saved draft (dryRun etc. via --args)", flags: ["id", "args", "args-file"] },
290
+ "draft-delete": { usage: "draft-delete <id>", description: "Delete a saved draft", flags: ["id"] },
291
+ "draft-thread-reply": { usage: "draft-thread-reply <threadId> (--body <text> | stdin) [--reply-all]", description: "Create a reply draft for a thread", flags: ["id", ...BODY_FLAGS, "reply-all", "all", ...DRAFT_COPY_FLAGS] },
292
+ tools: { usage: "tools", description: "List every MCP tool exposed by the server", flags: [] },
293
+ tool: { usage: "tool <name> [--args '{...}' | --args-file <path>]", description: "Call any MCP tool with JSON arguments", flags: ["args", "args-file"] },
294
+ };
295
+ const GLOBAL_FLAGS = ["json", "help", "version", "v"];
296
+ function specForCommand(command) {
297
+ const spec = COMMAND_SPECS[command];
298
+ if (spec)
299
+ return spec;
300
+ const entry = TOOL_ONLY_COMMANDS.find((candidate) => candidate.command === command);
301
+ if (!entry)
302
+ return undefined;
303
+ const positionals = entry.positionals.map((field) => ` <${field}>`).join("");
304
+ const fileFlag = entry.fileField || entry.fileFieldBase64 ? ["file"] : [];
305
+ return {
306
+ usage: `${entry.command}${positionals}${fileFlag.length ? " --file <path>" : ""} [--args '{...}' | --args-file <path>]`,
307
+ description: `${entry.help} (MCP tool ${entry.tool})`,
308
+ flags: ["args", "args-file", ...fileFlag, ...(entry.boolFlags ?? [])],
309
+ };
310
+ }
311
+ export function commandHelpText(command) {
312
+ const spec = specForCommand(command);
313
+ if (!spec)
314
+ return undefined;
315
+ const flags = [...new Set([...spec.flags, ...GLOBAL_FLAGS.filter((flag) => flag !== "v")])];
316
+ return [
317
+ `Usage: proton-mail-bridge ${spec.usage}`,
318
+ "",
319
+ spec.description,
320
+ "",
321
+ `Options: ${flags.map((flag) => `--${flag}`).join(" ")}`,
322
+ "",
323
+ "A value that starts with -- must be written --flag=value (or placed after a bare --).",
324
+ "",
325
+ ].join("\n");
326
+ }
327
+ // Rejects any flag the command does not read, instead of silently dropping it (a mistyped
328
+ // filter on a bulk command would otherwise widen what the command matches).
329
+ function assertKnownFlags(parsed) {
330
+ const spec = specForCommand(parsed.command);
331
+ if (!spec)
332
+ return;
333
+ const allowed = new Set([...GLOBAL_FLAGS, ...spec.flags]);
334
+ const unknown = Object.keys(parsed.flags).filter((flag) => !allowed.has(flag));
335
+ if (unknown.length === 0)
336
+ return;
337
+ const valid = [...allowed].filter((flag) => flag !== "v").map((flag) => `--${flag}`).join(", ");
338
+ throw new CliUsageError(`Unknown flag ${unknown.map((flag) => `--${flag}`).join(", ")} for ${parsed.command}. Valid flags: ${valid}. Run "${parsed.command} --help" for usage.`);
339
+ }
185
340
  function isTruthyFlag(value) {
186
341
  if (value === true) {
187
342
  return true;
@@ -195,30 +350,45 @@ function getStringFlag(flags, key) {
195
350
  const value = flags[key];
196
351
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
197
352
  }
198
- function getNumberFlag(flags, key, fallback) {
353
+ // Strict: digits only ("5x", "1.5", "-1" and "" are rejected rather than read as 5, 1, ...).
354
+ function parseIntegerFlag(flags, key, minimum, requirement) {
199
355
  const value = flags[key];
200
- if (typeof value !== "string" || !value.trim()) {
201
- return fallback;
356
+ if (value === undefined) {
357
+ return undefined;
202
358
  }
203
- const parsed = Number.parseInt(value, 10);
204
- if (!Number.isInteger(parsed) || parsed <= 0) {
205
- throw new Error(`--${key} must be a positive integer.`);
359
+ if (typeof value !== "string") {
360
+ throw new CliUsageError(`--${key} requires a value (${requirement}).`);
361
+ }
362
+ const trimmed = value.trim();
363
+ const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
364
+ if (!Number.isSafeInteger(parsed) || parsed < minimum) {
365
+ throw new CliUsageError(`--${key} must be ${requirement}, got "${value}".`);
206
366
  }
207
367
  return parsed;
208
368
  }
369
+ function getNumberFlag(flags, key, fallback) {
370
+ return parseIntegerFlag(flags, key, 1, "a positive integer") ?? fallback;
371
+ }
209
372
  // Same as getNumberFlag but for flags like --offset where 0 is the
210
373
  // documented default and a legitimate explicit value ("start from the
211
374
  // beginning"), not an error — only negative/non-integer values are invalid.
212
375
  function getOffsetFlag(flags, key, fallback) {
376
+ return parseIntegerFlag(flags, key, 0, "a non-negative integer") ?? fallback;
377
+ }
378
+ // Free text (bodies, notes): returned exactly as given, so leading indentation survives.
379
+ // A blank value counts as absent, like getStringFlag.
380
+ function getTextFlag(flags, key) {
213
381
  const value = flags[key];
214
- if (typeof value !== "string" || !value.trim()) {
215
- return fallback;
216
- }
217
- const parsed = Number.parseInt(value, 10);
218
- if (!Number.isInteger(parsed) || parsed < 0) {
219
- throw new Error(`--${key} must be a non-negative integer.`);
220
- }
221
- return parsed;
382
+ return typeof value === "string" && value.trim() ? value : undefined;
383
+ }
384
+ // A piped body: one trailing newline is the shell's, everything else is the author's. An
385
+ // all-whitespace stdin counts as no body.
386
+ async function readStdinBody() {
387
+ const chunks = [];
388
+ for await (const chunk of process.stdin)
389
+ chunks.push(Buffer.from(chunk));
390
+ const text = Buffer.concat(chunks).toString("utf8");
391
+ return text.trim() ? text.replace(/\r?\n$/, "") : undefined;
222
392
  }
223
393
  function json(value) {
224
394
  return `${JSON.stringify(value, null, 2)}\n`;
@@ -295,6 +465,31 @@ function printToolCallResult(result, wantJson) {
295
465
  .join("\n\n");
296
466
  process.stdout.write(`${rendered}\n`);
297
467
  }
468
+ // Tools whose result reports per-item failures: the call still returns a full JSON result,
469
+ // but a run where any item failed must not look like success to a script.
470
+ const ITEM_RESULT_TOOLS = new Set([
471
+ "batch_email_action", "bulk_delete", "bulk_move", "bulk_update_flags", "bulk_update_labels", "apply_thread_action",
472
+ ]);
473
+ // 1 for an error result, a failed item in a batch/bulk run, or a failed health check
474
+ // (get_connection_status / run_doctor report smtp.ok / imap.ok); 0 otherwise. The printed
475
+ // JSON is never altered by this.
476
+ export function toolResultExitCode(toolName, result) {
477
+ if (result.isError === true)
478
+ return 1;
479
+ const data = result.structuredContent;
480
+ if (!data || typeof data !== "object")
481
+ return 0;
482
+ const record = data;
483
+ if (ITEM_RESULT_TOOLS.has(toolName) && typeof record.failed === "number" && record.failed > 0)
484
+ return 1;
485
+ if (toolName === "get_connection_status" || toolName === "run_doctor") {
486
+ for (const side of [record.smtp, record.imap]) {
487
+ if (side && typeof side === "object" && side.ok === false)
488
+ return 1;
489
+ }
490
+ }
491
+ return 0;
492
+ }
298
493
  async function withMcpClient(run) {
299
494
  const transport = new StdioClientTransport({
300
495
  command: process.execPath,
@@ -308,6 +503,13 @@ async function withMcpClient(run) {
308
503
  }, {
309
504
  capabilities: {},
310
505
  });
506
+ const callTool = client.callTool.bind(client);
507
+ client.callTool = (async (params, ...rest) => {
508
+ const result = await callTool(params, ...rest);
509
+ if (toolResultExitCode(params.name, result) !== 0)
510
+ process.exitCode = 1;
511
+ return result;
512
+ });
311
513
  try {
312
514
  await client.connect(transport);
313
515
  return await run(client);
@@ -488,6 +690,8 @@ async function runDoctor(parsed) {
488
690
  error,
489
691
  };
490
692
  process.stdout.write(wantJson ? json(result) : `${result.ok ? "Doctor OK" : "Doctor failed"}\n${json(result)}`);
693
+ if (!result.ok)
694
+ process.exitCode = 1;
491
695
  });
492
696
  }
493
697
  async function runConnectionStatus(parsed) {
@@ -521,6 +725,8 @@ async function runConnectionStatus(parsed) {
521
725
  error,
522
726
  };
523
727
  process.stdout.write(wantJson ? json(result) : `${JSON.stringify(result, null, 2)}\n`);
728
+ if (!(imapOk && smtpOk))
729
+ process.exitCode = 1;
524
730
  });
525
731
  }
526
732
  async function runRuntimeStatus(parsed) {
@@ -655,21 +861,46 @@ async function runDrafts(parsed) {
655
861
  : result.map((draft, index) => `${index + 1}. ${draft.id} | ${draft.mode} | ${draft.subject}`).join("\n") + "\n");
656
862
  });
657
863
  }
864
+ // The single-message shortcuts below go through the same MCP tool handlers as every other
865
+ // client, so `<slug>::<id>` account routing, the uidValidity check, runtime policy
866
+ // (PROTONMAIL_ALLOWED_ACTIONS, CONFIRM_DESTRUCTIVE) and auditing all apply exactly as they
867
+ // do there. They used to call the primary account's IMAP service directly, bypassing all of it.
868
+ function structuredResult(result) {
869
+ if (result.structuredContent && typeof result.structuredContent === "object") {
870
+ return result.structuredContent;
871
+ }
872
+ const content = Array.isArray(result.content) ? result.content : [];
873
+ const text = content.find((entry) => entry.type === "text")?.text;
874
+ try {
875
+ const parsed = text ? JSON.parse(text) : undefined;
876
+ return parsed && typeof parsed === "object" ? parsed : {};
877
+ }
878
+ catch {
879
+ return {};
880
+ }
881
+ }
882
+ async function runToolShortcut(parsed, toolName, args, render) {
883
+ const wantJson = isTruthyFlag(parsed.flags.json);
884
+ await withMcpClient(async (client) => {
885
+ const result = (await client.callTool({ name: toolName, arguments: args }));
886
+ if (result.isError === true) {
887
+ printToolCallResult(result, wantJson);
888
+ return;
889
+ }
890
+ const data = structuredResult(result);
891
+ process.stdout.write(wantJson ? json(data) : render(data));
892
+ });
893
+ }
658
894
  async function runAttachments(parsed) {
659
895
  const emailId = parsed.positionals[0];
660
896
  if (!emailId) {
661
897
  throw new Error("attachments requires an emailId, for example: proton-mail-bridge attachments INBOX::123");
662
898
  }
663
- const wantJson = isTruthyFlag(parsed.flags.json);
664
- await withServices(async ({ imapService }) => {
665
- const result = await imapService.listAttachments(emailId);
666
- if (wantJson) {
667
- process.stdout.write(json(result));
668
- return;
669
- }
670
- process.stdout.write(result.attachments.length === 0
899
+ await runToolShortcut(parsed, "list_attachments", { emailId }, (data) => {
900
+ const attachments = Array.isArray(data.attachments) ? data.attachments : [];
901
+ return attachments.length === 0
671
902
  ? "No attachments.\n"
672
- : result.attachments.map((attachment, index) => `${index + 1}. ${attachment.filename || attachment.id || "(unnamed)"} | ${attachment.contentType || "unknown"} | ${attachment.kind || "other"}`).join("\n") + "\n");
903
+ : attachments.map((attachment, index) => `${index + 1}. ${attachment.filename || attachment.id || "(unnamed)"} | ${attachment.contentType || "unknown"} | ${attachment.kind || "other"}`).join("\n") + "\n";
673
904
  });
674
905
  }
675
906
  async function runSearch(parsed) {
@@ -677,22 +908,24 @@ async function runSearch(parsed) {
677
908
  const live = isTruthyFlag(parsed.flags.live);
678
909
  const syncBefore = isTruthyFlag(parsed.flags.sync);
679
910
  const filters = buildSearchFilters(parsed);
680
- await withServices(async (context) => {
681
- if (!live) {
682
- const status = await context.localIndexService.getStatus();
683
- if (syncBefore || !status.updatedAt) {
684
- await syncIndex(context, {
685
- folder: filters.folder,
686
- limitPerFolder: Math.max(filters.limit ?? 25, 100),
687
- includeAttachmentText: true,
688
- });
689
- }
690
- const result = await context.localIndexService.search(filters);
691
- process.stdout.write(wantJson ? json(result) : tableEmails(result.emails));
911
+ await withMcpClient(async (client) => {
912
+ // An empty index is refreshed by search_indexed_emails itself; --sync forces a refresh first.
913
+ if (!live && syncBefore) {
914
+ await client.callTool({
915
+ name: "sync_emails",
916
+ arguments: { folder: filters.folder, limitPerFolder: Math.max(filters.limit ?? 25, 100) },
917
+ });
918
+ }
919
+ const result = (await client.callTool({
920
+ name: live ? "search_emails" : "search_indexed_emails",
921
+ arguments: { ...filters },
922
+ }));
923
+ if (result.isError === true) {
924
+ printToolCallResult(result, wantJson);
692
925
  return;
693
926
  }
694
- const result = await context.imapService.searchEmails(filters);
695
- process.stdout.write(wantJson ? json(result) : tableEmails(result.emails));
927
+ const data = structuredResult(result);
928
+ process.stdout.write(wantJson ? json(data) : tableEmails(Array.isArray(data.emails) ? data.emails : []));
696
929
  });
697
930
  }
698
931
  async function runRead(parsed) {
@@ -700,22 +933,17 @@ async function runRead(parsed) {
700
933
  if (!emailId) {
701
934
  throw new Error("read requires an emailId, for example: proton-mail-bridge read INBOX::123");
702
935
  }
703
- const wantJson = isTruthyFlag(parsed.flags.json);
704
- await withServices(async ({ imapService }) => {
705
- const detail = await imapService.getEmailById(emailId);
706
- if (wantJson) {
707
- process.stdout.write(json(detail));
708
- return;
709
- }
936
+ await runToolShortcut(parsed, "get_email_by_id", { emailId }, (detail) => {
937
+ const from = Array.isArray(detail.from) ? detail.from : [];
710
938
  const lines = [
711
939
  `ID: ${detail.id}`,
712
940
  `Subject: ${detail.subject}`,
713
- `From: ${detail.from.map((entry) => entry.address || entry.name || "").filter(Boolean).join(", ")}`,
941
+ `From: ${from.map((entry) => entry.address || entry.name || "").filter(Boolean).join(", ")}`,
714
942
  `Date: ${detail.date || detail.internalDate || ""}`,
715
943
  "",
716
944
  detail.text || detail.preview || "(no text body available)",
717
945
  ];
718
- process.stdout.write(`${lines.join("\n")}\n`);
946
+ return `${lines.join("\n")}\n`;
719
947
  });
720
948
  }
721
949
  // ── write commands ──────────────────────────────────────────────────────────
@@ -726,102 +954,45 @@ async function runMove(parsed) {
726
954
  throw new Error("move requires an emailId");
727
955
  if (!targetFolder)
728
956
  throw new Error("move requires a target folder as a second argument or --folder");
729
- const wantJson = isTruthyFlag(parsed.flags.json);
730
- await withServices(async ({ config, imapService, auditService }) => {
731
- // Same bypass as archive/trash/restore/mark-read/star's fix above — this
732
- // called ensureMailboxWriteAllowed only, so PROTONMAIL_ALLOWED_ACTIONS
733
- // excluding "move" had no effect on this shortcut.
734
- ensureEmailActionAllowed(config.runtime, "move");
735
- // Found live: every write command in this file called the service
736
- // directly, so none of them ever produced an audit.log entry — unlike
737
- // the identical action through an MCP tool call, which withAudit always
738
- // records. Confirmed live: a real CLI `star` left audit.log's line
739
- // count unchanged. Mirrored across every write command below.
740
- const result = await withAudit(auditService, "move_email", { emailId, targetFolder }, () => imapService.moveEmail(emailId, targetFolder));
741
- process.stdout.write(wantJson ? json(result) : `Moved ${emailId} → ${result.targetFolder}\n`);
742
- });
957
+ await runToolShortcut(parsed, "move_email", { emailId, targetFolder }, (result) => `Moved ${emailId} → ${result.targetFolder}\n`);
743
958
  }
744
959
  async function runArchive(parsed) {
745
960
  const emailId = parsed.positionals[0];
746
961
  if (!emailId)
747
962
  throw new Error("archive requires an emailId");
748
- const wantJson = isTruthyFlag(parsed.flags.json);
749
- await withServices(async ({ config, imapService, auditService }) => {
750
- // Found live: this called the service directly, bypassing the MCP
751
- // tool layer's ensureEmailActionAllowed entirely — with
752
- // PROTONMAIL_ALLOWED_ACTIONS restricting which actions are permitted,
753
- // `tool trash_email` correctly refused a disallowed action, but the
754
- // matching CLI shortcuts (archive/trash/restore/mark-read/star) all
755
- // performed it anyway. Same fix mirrored across all five below.
756
- ensureEmailActionAllowed(config.runtime, "archive");
757
- const result = await withAudit(auditService, "archive_email", { emailId }, () => imapService.archiveEmail(emailId));
758
- process.stdout.write(wantJson ? json(result) : `Archived ${emailId} → ${result.targetFolder}\n`);
759
- });
963
+ await runToolShortcut(parsed, "archive_email", { emailId }, (result) => `Archived ${emailId} → ${result.targetFolder}\n`);
760
964
  }
761
965
  async function runTrash(parsed) {
762
966
  const emailId = parsed.positionals[0];
763
967
  if (!emailId)
764
968
  throw new Error("trash requires an emailId");
765
- const wantJson = isTruthyFlag(parsed.flags.json);
766
- await withServices(async ({ config, imapService, auditService }) => {
767
- ensureEmailActionAllowed(config.runtime, "trash");
768
- const result = await withAudit(auditService, "trash_email", { emailId }, () => imapService.trashEmail(emailId));
769
- process.stdout.write(wantJson ? json(result) : `Trashed ${emailId} → ${result.targetFolder}\n`);
770
- });
969
+ await runToolShortcut(parsed, "trash_email", { emailId }, (result) => `Trashed ${emailId} → ${result.targetFolder}\n`);
771
970
  }
772
971
  async function runRestore(parsed) {
773
972
  const emailId = parsed.positionals[0];
774
973
  if (!emailId)
775
974
  throw new Error("restore requires an emailId");
776
- const wantJson = isTruthyFlag(parsed.flags.json);
777
- await withServices(async ({ config, imapService, auditService }) => {
778
- ensureEmailActionAllowed(config.runtime, "restore");
779
- const targetFolder = getStringFlag(parsed.flags, "folder");
780
- const result = await withAudit(auditService, "restore_email", { emailId, targetFolder }, () => imapService.restoreEmail(emailId, targetFolder));
781
- process.stdout.write(wantJson ? json(result) : `Restored ${emailId} → ${result.targetFolder}\n`);
782
- });
975
+ await runToolShortcut(parsed, "restore_email", { emailId, targetFolder: getStringFlag(parsed.flags, "folder") }, (result) => `Restored ${emailId} → ${result.targetFolder}\n`);
783
976
  }
784
977
  async function runMarkRead(parsed) {
785
978
  const emailId = parsed.positionals[0];
786
979
  if (!emailId)
787
980
  throw new Error("mark-read requires an emailId");
788
981
  const isRead = !isTruthyFlag(parsed.flags.unread);
789
- const wantJson = isTruthyFlag(parsed.flags.json);
790
- await withServices(async ({ config, imapService, auditService }) => {
791
- ensureEmailActionAllowed(config.runtime, isRead ? "mark_read" : "mark_unread");
792
- const result = await withAudit(auditService, "mark_email_read", { emailId, isRead }, () => imapService.markEmailRead(emailId, isRead));
793
- process.stdout.write(wantJson ? json(result) : `Marked ${emailId} as ${result.isRead ? "read" : "unread"}\n`);
794
- });
982
+ await runToolShortcut(parsed, "mark_email_read", { emailId, isRead }, (result) => `Marked ${emailId} as ${result.isRead ? "read" : "unread"}\n`);
795
983
  }
796
984
  async function runStar(parsed) {
797
985
  const emailId = parsed.positionals[0];
798
986
  if (!emailId)
799
987
  throw new Error("star requires an emailId");
800
988
  const isStarred = !isTruthyFlag(parsed.flags.unstar);
801
- const wantJson = isTruthyFlag(parsed.flags.json);
802
- await withServices(async ({ config, imapService, auditService }) => {
803
- ensureEmailActionAllowed(config.runtime, isStarred ? "star" : "unstar");
804
- const result = await withAudit(auditService, "star_email", { emailId, isStarred }, () => imapService.starEmail(emailId, isStarred));
805
- process.stdout.write(wantJson ? json(result) : `${result.isStarred ? "Starred" : "Unstarred"} ${emailId}\n`);
806
- });
989
+ await runToolShortcut(parsed, "star_email", { emailId, isStarred }, (result) => `${result.isStarred ? "Starred" : "Unstarred"} ${emailId}\n`);
807
990
  }
808
991
  async function runDelete(parsed) {
809
992
  const emailId = parsed.positionals[0];
810
993
  if (!emailId)
811
994
  throw new Error("delete requires an emailId");
812
- const wantJson = isTruthyFlag(parsed.flags.json);
813
- await withServices(async ({ config, imapService, auditService }) => {
814
- // Same PROTONMAIL_ALLOWED_ACTIONS bypass as runMove's fix above.
815
- ensureEmailActionAllowed(config.runtime, "delete");
816
- // Found live: this called the service directly, bypassing the MCP
817
- // tool layer's ensureDestructiveConfirmed entirely — with
818
- // PROTONMAIL_CONFIRM_DESTRUCTIVE=true, `tool delete_email` correctly
819
- // refused without confirmed:true, but this shortcut permanently
820
- // deleted the message anyway, no confirmation asked or possible.
821
- ensureDestructiveConfirmed(config.runtime, isTruthyFlag(parsed.flags.confirmed), `Permanently delete ${emailId} (cannot be recovered)`);
822
- const result = await withAudit(auditService, "delete_email", { emailId }, () => imapService.deleteEmail(emailId));
823
- process.stdout.write(wantJson ? json(result) : `Deleted ${emailId}\n`);
824
- });
995
+ await runToolShortcut(parsed, "delete_email", { emailId, confirmed: isTruthyFlag(parsed.flags.confirmed) || undefined }, () => `Deleted ${emailId}\n`);
825
996
  }
826
997
  async function runSend(parsed) {
827
998
  const to = getStringFlag(parsed.flags, "to");
@@ -832,13 +1003,7 @@ async function runSend(parsed) {
832
1003
  throw new Error("send requires --to");
833
1004
  if (!subject)
834
1005
  throw new Error("send requires --subject");
835
- let body = getStringFlag(parsed.flags, "body");
836
- if (!body) {
837
- const chunks = [];
838
- for await (const chunk of process.stdin)
839
- chunks.push(Buffer.from(chunk));
840
- body = Buffer.concat(chunks).toString("utf8").trim();
841
- }
1006
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
842
1007
  if (!body)
843
1008
  throw new Error("send requires --body or body piped via stdin");
844
1009
  const wantJson = isTruthyFlag(parsed.flags.json);
@@ -903,12 +1068,9 @@ async function runSend(parsed) {
903
1068
  // when the server has a default undo window configured), but getNumberFlag rejects <= 0
904
1069
  // for the flags where only a positive count makes sense (limit, offset, ...).
905
1070
  function parseUndoWindowFlag(parsed) {
906
- const undoWindowFlag = getStringFlag(parsed.flags, "undo-window");
907
- if (undoWindowFlag === undefined)
908
- return undefined;
909
- const requested = Number.parseInt(undoWindowFlag, 10);
910
- if (!Number.isInteger(requested) || requested < 0 || requested > 300) {
911
- throw new Error("--undo-window must be an integer between 0 and 300.");
1071
+ const requested = parseIntegerFlag(parsed.flags, "undo-window", 0, "an integer between 0 and 300");
1072
+ if (requested !== undefined && requested > 300) {
1073
+ throw new CliUsageError(`--undo-window must be an integer between 0 and 300, got "${String(parsed.flags["undo-window"])}".`);
912
1074
  }
913
1075
  return requested;
914
1076
  }
@@ -926,13 +1088,7 @@ async function runReply(parsed) {
926
1088
  if (!emailId)
927
1089
  throw new Error("reply requires an emailId");
928
1090
  const replyAll = isTruthyFlag(parsed.flags["reply-all"]) || isTruthyFlag(parsed.flags.all);
929
- let body = getStringFlag(parsed.flags, "body");
930
- if (!body) {
931
- const chunks = [];
932
- for await (const chunk of process.stdin)
933
- chunks.push(Buffer.from(chunk));
934
- body = Buffer.concat(chunks).toString("utf8").trim();
935
- }
1091
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
936
1092
  if (!body)
937
1093
  throw new Error("reply requires --body or body piped via stdin");
938
1094
  const wantJson = isTruthyFlag(parsed.flags.json);
@@ -956,15 +1112,7 @@ async function runForward(parsed) {
956
1112
  const to = parseEmails(getStringFlag(parsed.flags, "to") || "");
957
1113
  if (to.length === 0)
958
1114
  throw new Error("forward requires --to");
959
- let body = getStringFlag(parsed.flags, "body");
960
- if (!body) {
961
- const stdinChunks = [];
962
- for await (const chunk of process.stdin)
963
- stdinChunks.push(Buffer.from(chunk));
964
- const stdinText = Buffer.concat(stdinChunks).toString("utf8").trim();
965
- if (stdinText)
966
- body = stdinText;
967
- }
1115
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
968
1116
  const wantJson = isTruthyFlag(parsed.flags.json);
969
1117
  // Same reasoning as runReply: use the shared MCP handler so send policy applies.
970
1118
  await withMcpClient(async (client) => {
@@ -1030,11 +1178,19 @@ async function runEmptyFolder(parsed) {
1030
1178
  printToolCallResult(result, wantJson);
1031
1179
  });
1032
1180
  }
1181
+ // The server resolves an empty `match` to the whole folder, so a bulk command with no filter
1182
+ // would act on up to every message in it. Refuse here, before anything connects.
1183
+ function requireBulkFilter(command, filters) {
1184
+ if (filters.every((filter) => !filter)) {
1185
+ throw new CliUsageError(`${command} requires at least one of --from, --subject, --since, --before (without a filter it would match the whole folder). Use --dry-run to preview what a filter matches.`);
1186
+ }
1187
+ }
1033
1188
  async function runBulkDelete(parsed) {
1034
1189
  const from = getStringFlag(parsed.flags, "from");
1035
1190
  const subject = getStringFlag(parsed.flags, "subject");
1036
1191
  const since = getStringFlag(parsed.flags, "since");
1037
1192
  const before = getStringFlag(parsed.flags, "before");
1193
+ requireBulkFilter("bulk-delete", [from, subject, since, before]);
1038
1194
  const wantJson = isTruthyFlag(parsed.flags.json);
1039
1195
  await withMcpClient(async (client) => {
1040
1196
  const result = await client.callTool({
@@ -1064,6 +1220,7 @@ async function runBulkMove(parsed) {
1064
1220
  const subject = getStringFlag(parsed.flags, "subject");
1065
1221
  const since = getStringFlag(parsed.flags, "since");
1066
1222
  const before = getStringFlag(parsed.flags, "before");
1223
+ requireBulkFilter("bulk-move", [from, subject, since, before]);
1067
1224
  const wantJson = isTruthyFlag(parsed.flags.json);
1068
1225
  await withMcpClient(async (client) => {
1069
1226
  const result = await client.callTool({
@@ -1374,7 +1531,7 @@ async function runTestEmail(parsed) {
1374
1531
  name: "send_test_email",
1375
1532
  arguments: {
1376
1533
  to,
1377
- customMessage: getStringFlag(parsed.flags, "message"),
1534
+ customMessage: getTextFlag(parsed.flags, "message"),
1378
1535
  confirmed: isTruthyFlag(parsed.flags.confirmed) || undefined,
1379
1536
  },
1380
1537
  });
@@ -1386,13 +1543,7 @@ async function runDraftCreate(parsed) {
1386
1543
  const subject = getStringFlag(parsed.flags, "subject");
1387
1544
  if (!subject)
1388
1545
  throw new Error("draft-create requires --subject");
1389
- let body = getStringFlag(parsed.flags, "body");
1390
- if (!body) {
1391
- const chunks = [];
1392
- for await (const chunk of process.stdin)
1393
- chunks.push(Buffer.from(chunk));
1394
- body = Buffer.concat(chunks).toString("utf8").trim();
1395
- }
1546
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
1396
1547
  if (!body)
1397
1548
  throw new Error("draft-create requires --body or body piped via stdin");
1398
1549
  const wantJson = isTruthyFlag(parsed.flags.json);
@@ -1418,15 +1569,7 @@ async function runDraftUpdate(parsed) {
1418
1569
  const draftId = parsed.positionals[0] || getStringFlag(parsed.flags, "id");
1419
1570
  if (!draftId)
1420
1571
  throw new Error("draft-update requires a draft id");
1421
- let body = getStringFlag(parsed.flags, "body");
1422
- if (!body) {
1423
- const chunks = [];
1424
- for await (const chunk of process.stdin)
1425
- chunks.push(Buffer.from(chunk));
1426
- const text = Buffer.concat(chunks).toString("utf8").trim();
1427
- if (text)
1428
- body = text;
1429
- }
1572
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
1430
1573
  const wantJson = isTruthyFlag(parsed.flags.json);
1431
1574
  await withMcpClient(async (client) => {
1432
1575
  const result = await client.callTool({
@@ -1438,7 +1581,7 @@ async function runDraftUpdate(parsed) {
1438
1581
  bcc: getStringFlag(parsed.flags, "bcc"),
1439
1582
  subject: getStringFlag(parsed.flags, "subject"),
1440
1583
  body: body || undefined,
1441
- notes: getStringFlag(parsed.flags, "notes"),
1584
+ notes: getTextFlag(parsed.flags, "notes"),
1442
1585
  },
1443
1586
  });
1444
1587
  printToolCallResult(result, wantJson);
@@ -1448,15 +1591,7 @@ async function runDraftReply(parsed) {
1448
1591
  const emailId = parsed.positionals[0] || getStringFlag(parsed.flags, "id");
1449
1592
  if (!emailId)
1450
1593
  throw new Error("draft-reply requires an emailId");
1451
- let body = getStringFlag(parsed.flags, "body");
1452
- if (!body) {
1453
- const chunks = [];
1454
- for await (const chunk of process.stdin)
1455
- chunks.push(Buffer.from(chunk));
1456
- const text = Buffer.concat(chunks).toString("utf8").trim();
1457
- if (text)
1458
- body = text;
1459
- }
1594
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
1460
1595
  if (!body)
1461
1596
  throw new Error("draft-reply requires --body or body piped via stdin");
1462
1597
  const wantJson = isTruthyFlag(parsed.flags.json);
@@ -1469,7 +1604,7 @@ async function runDraftReply(parsed) {
1469
1604
  replyAll: isTruthyFlag(parsed.flags["reply-all"]) || isTruthyFlag(parsed.flags.all) || undefined,
1470
1605
  cc: getStringFlag(parsed.flags, "cc"),
1471
1606
  bcc: getStringFlag(parsed.flags, "bcc"),
1472
- notes: getStringFlag(parsed.flags, "notes"),
1607
+ notes: getTextFlag(parsed.flags, "notes"),
1473
1608
  },
1474
1609
  });
1475
1610
  printToolCallResult(result, wantJson);
@@ -1482,20 +1617,12 @@ async function runDraftForward(parsed) {
1482
1617
  throw new Error("draft-forward requires an emailId");
1483
1618
  if (!to)
1484
1619
  throw new Error("draft-forward requires --to");
1485
- let body = getStringFlag(parsed.flags, "body");
1486
- if (!body) {
1487
- const chunks = [];
1488
- for await (const chunk of process.stdin)
1489
- chunks.push(Buffer.from(chunk));
1490
- const text = Buffer.concat(chunks).toString("utf8").trim();
1491
- if (text)
1492
- body = text;
1493
- }
1620
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
1494
1621
  const wantJson = isTruthyFlag(parsed.flags.json);
1495
1622
  await withMcpClient(async (client) => {
1496
1623
  const result = await client.callTool({
1497
1624
  name: "create_forward_draft",
1498
- arguments: { emailId, to, body: body || undefined, cc: getStringFlag(parsed.flags, "cc"), bcc: getStringFlag(parsed.flags, "bcc"), notes: getStringFlag(parsed.flags, "notes") },
1625
+ arguments: { emailId, to, body: body || undefined, cc: getStringFlag(parsed.flags, "cc"), bcc: getStringFlag(parsed.flags, "bcc"), notes: getTextFlag(parsed.flags, "notes") },
1499
1626
  });
1500
1627
  printToolCallResult(result, wantJson);
1501
1628
  });
@@ -1524,15 +1651,7 @@ async function runDraftThreadReply(parsed) {
1524
1651
  const threadId = parsed.positionals[0] || getStringFlag(parsed.flags, "id");
1525
1652
  if (!threadId)
1526
1653
  throw new Error("draft-thread-reply requires a threadId");
1527
- let body = getStringFlag(parsed.flags, "body");
1528
- if (!body) {
1529
- const chunks = [];
1530
- for await (const chunk of process.stdin)
1531
- chunks.push(Buffer.from(chunk));
1532
- const text = Buffer.concat(chunks).toString("utf8").trim();
1533
- if (text)
1534
- body = text;
1535
- }
1654
+ const body = getTextFlag(parsed.flags, "body") ?? (await readStdinBody());
1536
1655
  if (!body)
1537
1656
  throw new Error("draft-thread-reply requires --body or body piped via stdin");
1538
1657
  const wantJson = isTruthyFlag(parsed.flags.json);
@@ -1545,7 +1664,7 @@ async function runDraftThreadReply(parsed) {
1545
1664
  replyAll: isTruthyFlag(parsed.flags["reply-all"]) || isTruthyFlag(parsed.flags.all) || undefined,
1546
1665
  cc: getStringFlag(parsed.flags, "cc"),
1547
1666
  bcc: getStringFlag(parsed.flags, "bcc"),
1548
- notes: getStringFlag(parsed.flags, "notes"),
1667
+ notes: getTextFlag(parsed.flags, "notes"),
1549
1668
  },
1550
1669
  });
1551
1670
  printToolCallResult(result, wantJson);
@@ -1651,7 +1770,7 @@ export const TOOL_ONLY_COMMANDS = [
1651
1770
  { command: "rename-label", tool: "rename_label", positionals: ["name", "newName"], help: "Rename a Proton label" },
1652
1771
  { command: "delete-label", tool: "delete_label", positionals: ["name"], help: "Delete a Proton label" },
1653
1772
  { command: "get-connection-status", tool: "get_connection_status", positionals: [], help: "(tool form; see also `connection-status`)" },
1654
- { command: "list-accounts", tool: "list_accounts", positionals: [], help: "List the configured Proton addresses with each one's connection status and index freshness (multi-account setups; pass --checkConnections to verify live)" },
1773
+ { command: "list-accounts", tool: "list_accounts", positionals: [], boolFlags: ["checkConnections"], help: "List the configured Proton addresses with each one's connection status and index freshness (multi-account setups; pass --checkConnections to verify live)" },
1655
1774
  { command: "get-runtime-status", tool: "get_runtime_status", positionals: [], help: "(tool form; see also `runtime-status`)" },
1656
1775
  { command: "run-doctor", tool: "run_doctor", positionals: [], help: "Full production health check (tool form; see also `doctor`)" },
1657
1776
  { command: "run-background-sync", tool: "run_background_sync", positionals: [], help: "Trigger the configured background sync cycle now" },
@@ -1693,6 +1812,10 @@ async function runToolOnlyCommand(entry, parsed) {
1693
1812
  }
1694
1813
  }
1695
1814
  }
1815
+ for (const flag of entry.boolFlags ?? []) {
1816
+ if (isTruthyFlag(parsed.flags[flag]))
1817
+ args[flag] = true;
1818
+ }
1696
1819
  Object.assign(args, (await parseToolArgs(parsed)) ?? {});
1697
1820
  const requiredFileField = entry.fileFieldBase64 ?? entry.fileField;
1698
1821
  if (requiredFileField && args[requiredFileField] === undefined) {
@@ -1724,6 +1847,17 @@ async function runClaude(parsed) {
1724
1847
  throw new Error("claude requires one of: setup, install, update, check, doctor");
1725
1848
  }
1726
1849
  }
1850
+ function printCommandHelp(command) {
1851
+ if (!command) {
1852
+ printHelp();
1853
+ return;
1854
+ }
1855
+ const text = commandHelpText(command);
1856
+ if (!text) {
1857
+ throw new Error(`Unknown command: ${command}`);
1858
+ }
1859
+ process.stdout.write(text);
1860
+ }
1727
1861
  export async function main() {
1728
1862
  const parsed = parseCliArgs(process.argv.slice(2));
1729
1863
  // --version is parsed as a flag by the arg parser, not a positional command
@@ -1731,11 +1865,15 @@ export async function main() {
1731
1865
  process.stdout.write(`proton-mail-bridge-client ${await getPkgVersion()}\n`);
1732
1866
  return;
1733
1867
  }
1868
+ // --help / -h anywhere prints help and returns: nothing below runs, nothing connects.
1869
+ if (parsed.flags["help"]) {
1870
+ printCommandHelp(parsed.command === "help" ? parsed.positionals[0] : parsed.command);
1871
+ return;
1872
+ }
1873
+ assertKnownFlags(parsed);
1734
1874
  switch (parsed.command) {
1735
1875
  case "help":
1736
- case "--help":
1737
- case "-h":
1738
- printHelp();
1876
+ printCommandHelp(parsed.positionals[0]);
1739
1877
  return;
1740
1878
  case "-v":
1741
1879
  case "version":
@@ -1942,9 +2080,17 @@ export async function main() {
1942
2080
  }
1943
2081
  const isDirectExecution = isMainModule(import.meta.url);
1944
2082
  if (isDirectExecution) {
2083
+ // `proton-mail-bridge ... | head` closes the pipe early: that is the reader's choice, not an
2084
+ // error, so leave quietly. Anything else on stdout is still fatal.
2085
+ process.stdout.on("error", (error) => {
2086
+ if (error.code === "EPIPE") {
2087
+ process.exit(0);
2088
+ }
2089
+ throw error;
2090
+ });
1945
2091
  main().catch((error) => {
1946
2092
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1947
- process.exit(1);
2093
+ process.exit(error instanceof CliUsageError ? 2 : 1);
1948
2094
  });
1949
2095
  }
1950
2096
  //# sourceMappingURL=cli.js.map