proton-mail-bridge-client 2.1.26 → 2.1.27
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 +60 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -4
- package/dist/index.js.map +1 -1
- package/dist/services/draft-store-service.d.ts +10 -0
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +31 -1
- package/dist/services/draft-store-service.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -539,6 +539,63 @@ all shared types (`ProtonMailConfig`, `EmailSummary`, `EmailDetail`, …), `plan
|
|
|
539
539
|
|
|
540
540
|
---
|
|
541
541
|
|
|
542
|
+
## Performance and token cost
|
|
543
|
+
|
|
544
|
+
Measured against a real Proton Bridge with a ~57,000-message mailbox (INBOX 29k, Archive 23k, Sent 5k), September 2026. Your numbers depend on mailbox size and machine; the ratios are what to rely on.
|
|
545
|
+
|
|
546
|
+
| Call | Typical time |
|
|
547
|
+
|---|---|
|
|
548
|
+
| `search_indexed_emails` (local SQLite index) | ~10 ms |
|
|
549
|
+
| `get_emails` (3 messages) | ~0.2 s |
|
|
550
|
+
| `search_emails` with a `label`, or `folder` set to a small folder | < 1 s |
|
|
551
|
+
| `search_emails` in one large folder (INBOX, 29k) | ~2 s |
|
|
552
|
+
| `search_emails` with only `dateFrom`/`dateTo` in INBOX (29k) | ~1 s |
|
|
553
|
+
| `search_emails` with only `dateFrom`/`dateTo`, every real folder | ~2–2.5 s |
|
|
554
|
+
| `search_emails` with `subject` or `from` in INBOX | ~1.6 s |
|
|
555
|
+
| `search_emails` with free-text `query` in INBOX | ~2.5 s |
|
|
556
|
+
| `search_emails` with `subject` or `query`, every real folder | ~5.5–7 s |
|
|
557
|
+
| `search_emails` in a 50k-message label folder | ~3 s |
|
|
558
|
+
| `list_remote_drafts` (79 drafts) | ~15 ms |
|
|
559
|
+
|
|
560
|
+
Bridge answers IMAP `SEARCH` itself, and its cost grows with folder size. That is why a live search is slower than the local index, and why narrowing the scope matters most.
|
|
561
|
+
|
|
562
|
+
**Make searches faster**
|
|
563
|
+
|
|
564
|
+
- **Prefer `search_indexed_emails`** when the index is current (`get_index_status`, `sync_emails`). It does not touch Bridge at all.
|
|
565
|
+
- **Pass `folder`** when you know it. With no `folder`, `search_emails` covers every *real* folder and skips **All Mail**, **Labels/\*** and **Starred**: those are views of mail that already lives in INBOX/Sent/Archive, and scanning them made an all-folders search about three times slower and inflated `totalMatched`. Pass `folder: "All Mail"` (or `"Labels/x"`) to search a view on purpose.
|
|
566
|
+
- **Use `label` for labels.** On Proton Bridge a label is the folder `Labels/<name>`, so `label: "Newsletters"` searches only that folder. A name that matches no label answers immediately with nothing instead of scanning.
|
|
567
|
+
- **Date filters are the cheapest filter Bridge offers** — about 1 s in INBOX and 2–2.5 s across every real folder, against 1.6 s / 5.5 s for `subject` and 2.5 s / 6–7 s for free-text `query`. When you can say *when* ("last week", "this month"), lead with `dateFrom`/`dateTo`. Even a wide range is fine: everything older than a year (about 20,000 messages) still took under 2 s.
|
|
568
|
+
- **Adding a date range to a text search does not make the text search faster.** In INBOX, `dateFrom` (last 30 days) + `subject` took about as long as `subject` alone (1.6 s vs 1.6 s), and + `query` 2.1 s vs 2.5 s: Bridge still does the text work. Use dates on their own to find the window, then search inside it (for example by `folder`, or by fetching that window and reading it).
|
|
569
|
+
- Free-text `query` searches message bodies on Bridge's side and is the slowest filter; `subject` and `from` are cheaper.
|
|
570
|
+
- (Single runs on one machine, so read them as ratios, not guarantees.)
|
|
571
|
+
|
|
572
|
+
**Keep responses (and token use) small**
|
|
573
|
+
|
|
574
|
+
- List and search results are compact by default: attachments are metadata only, and empty or derivable fields are left out. Pass `fields: ["subject","from","date"]` to trim further — 10 results is roughly 800 tokens that way, versus about 11,000 for 50 full results.
|
|
575
|
+
- Defaults are bounded: `search_emails`/`search_indexed_emails` return 50 (pass `limit` for more), `list_drafts` 20, `list_scheduled_sends` 50 newest first. Each reports `hasMore`; page with `offset`.
|
|
576
|
+
- Draft and queued-message bodies are shortened to a preview in list and update responses (`bodyTruncated`, `bodyLength`); use `get_draft` for one full draft. Attachment content is never inlined in a listing.
|
|
577
|
+
- Large attachments: set `PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR` and pass `saveTo`, rather than raising `PROTONMAIL_MAX_INLINE_BYTES`.
|
|
578
|
+
- **`PROTONMAIL_TOOL_TIER=core`** exposes 25 tools instead of 96: about 7k tokens of tool definitions instead of about 20k (roughly 65% less, reference tokenizer), paid on every conversation that loads the server.
|
|
579
|
+
- Repeating an `update_draft` that changes nothing on a draft whose remote copy is already in sync does no store write and no IMAP upload. (A draft whose last sync failed, or that was edited with `syncToRemote:false`, still syncs.)
|
|
580
|
+
|
|
581
|
+
**Working with drafts**
|
|
582
|
+
|
|
583
|
+
Drafts live in a local store first; the copy in Proton's Drafts folder is a separate upload.
|
|
584
|
+
|
|
585
|
+
- **Edit a long draft with `bodyEdits`, not by resending `body`.** `update_draft` takes `bodyEdits: [{find, replace}]` (optionally `all:true`) and changes just those fragments of the stored body: a two-sentence change to a ~14,000-character draft is a request of about 200 characters instead of about 14,000 (roughly 70 times smaller), and the model only has to write the words that change. Each `find` must match exactly once (add surrounding text, or set `all:true`); otherwise nothing is changed and the error says which edit failed and why. Edits apply in order, all-or-nothing, and match the stored text literally — for an HTML draft, the HTML source. Pass either `body` or `bodyEdits`, not both. The response reports `bodyEditsApplied`.
|
|
586
|
+
- **Each `update_draft` uploads the whole draft to the Drafts folder** (the full message, attachment bytes included — about 1.4 MB of MIME for a 1 MiB attachment) unless you pass `syncToRemote:false`. When you are making several edits in a row, do them with `syncToRemote:false` and finish with one `sync_draft_to_remote`, or fold them into a single `update_draft`.
|
|
587
|
+
- **The response stays small however big the draft is.** `create_draft`, the reply/forward/thread-reply draft creators, `update_draft` and `sync_draft_to_remote` return a body preview and attachment metadata only (an edit of a ~200 KB draft cost about 300 tokens; a draft with a 1 MiB attachment about 220–300). Only `get_draft` returns the full body and attachment content.
|
|
588
|
+
- **Nothing to do if nothing changed:** repeating an `update_draft` that changes nothing on an already-synced draft does no write and no upload.
|
|
589
|
+
- `list_drafts` is local and returns the 20 most recently updated (`hasMore`, `offset` to page); `list_remote_drafts` reads the Drafts folder over IMAP (about 15 ms for 79 drafts).
|
|
590
|
+
- **Large attachments make every draft operation slower.** The store is one JSON file, so an edit reads and rewrites the whole file, attachment content of *every* draft included. In a benchmark (mocked IMAP), a tiny edit took about 1 ms normally and about 15 ms when a different draft held a 10 MiB attachment. If you edit drafts constantly, attach big files as late as possible.
|
|
591
|
+
- A draft cannot be edited while it is being sent, and simultaneous syncs of the same draft are serialized (so two clients cannot upload two copies).
|
|
592
|
+
|
|
593
|
+
**IMAP IDLE and the shared connection.** The server keeps one IMAP connection per account. The IDLE watcher (`PROTONMAIL_IDLE_WATCH`, on by default) waits on it, and since 2.1.25 it steps aside as soon as another operation needs the mailbox, so it no longer adds delay. Before that, with IDLE on, a single-folder search took 26–31 s and a multi-folder search did not finish.
|
|
594
|
+
|
|
595
|
+
To check a real Bridge yourself, run the read-only smoke test: `PROTONMAIL_USERNAME=… PROTONMAIL_PASSWORD=… node test/live/readonly-smoke.mjs`. It uses `PROTONMAIL_READ_ONLY=true` and a throwaway data directory, sends nothing, and prints counts and timings only.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
542
599
|
## Operational notes
|
|
543
600
|
|
|
544
601
|
- `get_emails` and `search_emails` return a composite `emailId` — use it for all subsequent reads and actions.
|
|
@@ -565,6 +622,9 @@ This reinstalls the runtime and rebuilds native modules in place.
|
|
|
565
622
|
**Claude can't see the connector**
|
|
566
623
|
After changing the MCP config, restart Claude Desktop fully (not just reload). Then check **`+` → Connectors → proton-mail-bridge**. If it's not there, run `proton-mail-bridge-client doctor` to validate the connection.
|
|
567
624
|
|
|
625
|
+
**Searches take ~30 seconds, or time out**
|
|
626
|
+
Upgrade to 2.1.25 or later. Before that, the IMAP IDLE watcher held the shared connection for its whole idle period and every other operation waited behind it (see [Performance and token cost](#performance-and-token-cost)). On a current version, a slow `search_emails` is Bridge's own `SEARCH` over a large folder: pass `folder` or `label`, or use `search_indexed_emails`.
|
|
627
|
+
|
|
568
628
|
**Folder not found when moving email**
|
|
569
629
|
Use `Folders/Name` for real folders (e.g., `Folders/Receipts`), not just `Name`. Labels and folders share the same namespace in Proton Bridge but are structurally different.
|
|
570
630
|
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AASA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAYnE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
11
11
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { isMainModule } from "./is-main.js";
|
|
13
13
|
import { AnalyticsService } from "./services/analytics-service.js";
|
|
14
|
-
import { draftSyncFingerprint } from "./services/draft-store-service.js";
|
|
14
|
+
import { applyBodyEdits, draftSyncFingerprint } from "./services/draft-store-service.js";
|
|
15
15
|
import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
|
|
16
16
|
import { applySignature, plainTextToHtml } from "./services/smtp-service.js";
|
|
17
17
|
import { ensureValidEmails, foldQuotedHistory, isTextLikeMimeType, isValidEmail, lowerCaseAddress, normalizeBoolean, normalizeLimit, normalizeJsonValue, parseEmailId, parseEmails, projectFields, trimAttachmentsForListing, renderMarkdown, slugifyAccountAddress, splitAccountPrefix, stringifyForJson, withAccountPrefix, } from "./utils/helpers.js";
|
|
@@ -439,7 +439,20 @@ const TOOLS = [
|
|
|
439
439
|
cc: { type: "string", description: "CC recipient email addresses, comma-separated." },
|
|
440
440
|
bcc: { type: "string", description: "BCC recipient email addresses, comma-separated." },
|
|
441
441
|
subject: { type: "string", description: "Draft subject." },
|
|
442
|
-
body: { type: "string", description: "Draft body." },
|
|
442
|
+
body: { type: "string", description: "Draft body. Replaces the WHOLE body — for a change to part of a long draft use bodyEdits instead, which costs a fraction of the tokens." },
|
|
443
|
+
bodyEdits: {
|
|
444
|
+
type: "array",
|
|
445
|
+
description: "Change parts of the existing body without resending it: find/replace edits applied in order (use instead of body; not both). Each find must appear exactly once in the body (add surrounding text to make it unique, or set all:true), otherwise nothing is changed and the error says why. replace:'' deletes the text. Matches the stored body literally — for an HTML draft that means the HTML source. The response reports bodyEditsApplied (number of replacements).",
|
|
446
|
+
items: {
|
|
447
|
+
type: "object",
|
|
448
|
+
properties: {
|
|
449
|
+
find: { type: "string", description: "Exact text to find (non-empty)." },
|
|
450
|
+
replace: { type: "string", description: "Replacement text; '' deletes." },
|
|
451
|
+
all: { type: "boolean", description: "Replace every occurrence instead of requiring exactly one.", default: false },
|
|
452
|
+
},
|
|
453
|
+
required: ["find", "replace"],
|
|
454
|
+
},
|
|
455
|
+
},
|
|
443
456
|
isHtml: { type: "boolean", description: "Whether the body should be HTML." },
|
|
444
457
|
priority: { type: "string", enum: ["high", "normal", "low"] },
|
|
445
458
|
replyTo: { type: "string", description: "Optional reply-to email address." },
|
|
@@ -1885,6 +1898,23 @@ export async function withAudit(auditService, tool, input, operation) {
|
|
|
1885
1898
|
throw error;
|
|
1886
1899
|
}
|
|
1887
1900
|
}
|
|
1901
|
+
// Validates update_draft's `bodyEdits` argument into BodyEdit[] (shape only; whether each
|
|
1902
|
+
// find text is present/unambiguous is decided against the stored body by applyBodyEdits).
|
|
1903
|
+
function parseBodyEdits(value) {
|
|
1904
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1905
|
+
throw new McpError(ErrorCode.InvalidParams, "bodyEdits must be a non-empty array of {find, replace, all?} objects.");
|
|
1906
|
+
}
|
|
1907
|
+
return value.map((entry, index) => {
|
|
1908
|
+
const edit = asObject(entry);
|
|
1909
|
+
if (typeof edit.find !== "string" || edit.find.length === 0) {
|
|
1910
|
+
throw new McpError(ErrorCode.InvalidParams, `bodyEdits[${index}].find must be a non-empty string.`);
|
|
1911
|
+
}
|
|
1912
|
+
if (typeof edit.replace !== "string") {
|
|
1913
|
+
throw new McpError(ErrorCode.InvalidParams, `bodyEdits[${index}].replace must be a string (use "" to delete the text).`);
|
|
1914
|
+
}
|
|
1915
|
+
return { find: edit.find, replace: edit.replace, ...(edit.all === true ? { all: true } : {}) };
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1888
1918
|
function asObject(value) {
|
|
1889
1919
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1890
1920
|
return {};
|
|
@@ -4541,6 +4571,10 @@ export function createServer(config, options = {}) {
|
|
|
4541
4571
|
// comment — so an explicit body:'' or replyTo:'' actually clears the
|
|
4542
4572
|
// field instead of being silently treated as "field not provided".
|
|
4543
4573
|
const body = optionalClearableString(args, "body");
|
|
4574
|
+
const bodyEdits = args.bodyEdits === undefined ? undefined : parseBodyEdits(args.bodyEdits);
|
|
4575
|
+
if (bodyEdits && body !== undefined) {
|
|
4576
|
+
throw new McpError(ErrorCode.InvalidParams, "Pass either body (replace the whole body) or bodyEdits (change fragments), not both.");
|
|
4577
|
+
}
|
|
4544
4578
|
const replyTo = optionalClearableString(args, "replyTo");
|
|
4545
4579
|
const from = optionalString(args, "from");
|
|
4546
4580
|
const priority = optionalString(args, "priority");
|
|
@@ -4566,6 +4600,7 @@ export function createServer(config, options = {}) {
|
|
|
4566
4600
|
bcc,
|
|
4567
4601
|
subject: optionalString(args, "subject"),
|
|
4568
4602
|
body,
|
|
4603
|
+
bodyEdits,
|
|
4569
4604
|
isHtml: typeof args.isHtml === "boolean" ? args.isHtml : undefined,
|
|
4570
4605
|
priority: priority === "high" || priority === "low" || priority === "normal"
|
|
4571
4606
|
? priority
|
|
@@ -4575,9 +4610,24 @@ export function createServer(config, options = {}) {
|
|
|
4575
4610
|
attachments,
|
|
4576
4611
|
notes: optionalString(args, "notes"),
|
|
4577
4612
|
};
|
|
4613
|
+
let bodyEditsApplied;
|
|
4578
4614
|
const result = await withAudit(auditService, name, args, async () => {
|
|
4579
4615
|
const existingDraft = await updateDraftBundle.draftStore.getDraft(updateDraftIdRest);
|
|
4580
|
-
|
|
4616
|
+
// For the no-op check only: what the body would become. The store re-applies
|
|
4617
|
+
// the edits to the stored body under its own lock, so a concurrent edit of
|
|
4618
|
+
// another fragment is never overwritten.
|
|
4619
|
+
let notedBody;
|
|
4620
|
+
if (bodyEdits) {
|
|
4621
|
+
try {
|
|
4622
|
+
const preview = applyBodyEdits(existingDraft.body, bodyEdits);
|
|
4623
|
+
notedBody = preview.body;
|
|
4624
|
+
bodyEditsApplied = preview.replacements;
|
|
4625
|
+
}
|
|
4626
|
+
catch (error) {
|
|
4627
|
+
throw new McpError(ErrorCode.InvalidParams, error instanceof Error ? error.message : String(error));
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
const noop = isNoopDraftPatch(existingDraft, bodyEdits ? { ...draftPatch, bodyEdits: undefined, body: notedBody } : draftPatch);
|
|
4581
4631
|
// Only skip the resync when the remote copy is known to be current. A
|
|
4582
4632
|
// draft whose last sync failed (or never ran) must still retry it, even
|
|
4583
4633
|
// if this particular call changes nothing locally.
|
|
@@ -4612,7 +4662,9 @@ export function createServer(config, options = {}) {
|
|
|
4612
4662
|
// caller already knows what it attached and what body it just sent; get_draft
|
|
4613
4663
|
// remains full-content for the "let me look at this draft" case, which is a
|
|
4614
4664
|
// single call rather than a loop.
|
|
4615
|
-
return createTextResult(
|
|
4665
|
+
return createTextResult((bodyEditsApplied === undefined
|
|
4666
|
+
? truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result))
|
|
4667
|
+
: { ...truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result)), bodyEditsApplied }), false, [
|
|
4616
4668
|
draftSource(result),
|
|
4617
4669
|
...(result.remoteDraft?.emailId
|
|
4618
4670
|
? [
|