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.
- package/README.md +8 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +348 -202
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -143
- package/dist/index.js.map +1 -1
- package/dist/scripts/check-claude-desktop.d.ts +18 -0
- package/dist/scripts/check-claude-desktop.d.ts.map +1 -1
- package/dist/scripts/check-claude-desktop.js +91 -30
- package/dist/scripts/check-claude-desktop.js.map +1 -1
- package/dist/scripts/install-claude-desktop.d.ts +12 -0
- package/dist/scripts/install-claude-desktop.d.ts.map +1 -1
- package/dist/scripts/install-claude-desktop.js +211 -43
- package/dist/scripts/install-claude-desktop.js.map +1 -1
- package/dist/services/analytics-service.d.ts +1 -1
- package/dist/services/analytics-service.d.ts.map +1 -1
- package/dist/services/analytics-service.js +2 -2
- package/dist/services/analytics-service.js.map +1 -1
- package/dist/services/audit-service.d.ts.map +1 -1
- package/dist/services/audit-service.js +7 -2
- package/dist/services/audit-service.js.map +1 -1
- package/dist/services/background-sync-service.d.ts.map +1 -1
- package/dist/services/background-sync-service.js +10 -2
- package/dist/services/background-sync-service.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts +4 -1
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +21 -27
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +13 -25
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +2 -1
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +91 -33
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +22 -2
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +295 -107
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts.map +1 -1
- package/dist/services/smtp-service.js +24 -2
- package/dist/services/smtp-service.js.map +1 -1
- package/dist/services/snooze-service.d.ts.map +1 -1
- package/dist/services/snooze-service.js +43 -30
- package/dist/services/snooze-service.js.map +1 -1
- package/dist/services/template-service.d.ts +4 -2
- package/dist/services/template-service.d.ts.map +1 -1
- package/dist/services/template-service.js +46 -20
- package/dist/services/template-service.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/atomic-write.d.ts +2 -0
- package/dist/utils/atomic-write.d.ts.map +1 -0
- package/dist/utils/atomic-write.js +36 -0
- package/dist/utils/atomic-write.js.map +1 -0
- package/dist/utils/corrupt-store.d.ts +4 -0
- package/dist/utils/corrupt-store.d.ts.map +1 -0
- package/dist/utils/corrupt-store.js +29 -0
- package/dist/utils/corrupt-store.js.map +1 -0
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +39 -4
- package/dist/utils/file-lock.js.map +1 -1
- package/dist/utils/helpers.d.ts +14 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +281 -27
- package/dist/utils/helpers.js.map +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +29 -12
- package/dist/utils/logger.js.map +1 -1
- 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", "
|
|
33
|
-
"permanent", "read", "reply-all", "sent", "starred", "sync", "unread",
|
|
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 (
|
|
50
|
+
if (optionsEnded) {
|
|
42
51
|
positionals.push(token);
|
|
43
52
|
continue;
|
|
44
53
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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 (
|
|
201
|
-
return
|
|
356
|
+
if (value === undefined) {
|
|
357
|
+
return undefined;
|
|
202
358
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
:
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
695
|
-
process.stdout.write(wantJson ? json(
|
|
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
|
-
|
|
704
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
907
|
-
if (
|
|
908
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|