Skip to content

Releases: googlarz/proton-mail-bridge-client

v2.0.8

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:36

Fixed

  • CI's npm audit --audit-level=high started failing on a newly-published high-severity advisory against nodemailer <=9.1.0 (affecting resolveContent()'s legacy-signature file/URL-access bypass, an IDN/punycode allow-list bypass, a quadratic-time address-parser DoS, and an RFC 5322 comment-parsing domain-validation bypass) — none of which this codebase's own nodemailer usage triggers, but the audit gate has no way to know that. Bumped the direct nodemailer dependency to ^9.1.1 (same major, no API change) and mailparser picked up its own patched nested nodemailer via npm audit fix. hono (a transitive dependency of @modelcontextprotocol/sdk, moderate severity) was also resolved by the same npm audit fix run. No source changes; npm audit now reports 0 vulnerabilities.

v2.0.7

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Sixth adversarial review round, following up on real-mailbox testing of v2.0.6. Eight confirmed findings, fixed and verified with new regression tests (299 → 310).

Fixed

  • list_drafts could permanently brick the MCP session on a large attachment. It has no filter and returns every draft unconditionally; DraftRecord.attachments carries full base64 content, and the response serializes the whole payload twice (text and structuredContent), so one large attachment on any draft could exceed the MCP stdio client's read buffer on every call — including the next session's startup listing. Attachment content is now redacted (filename/type/size only) in list_drafts; get_draft is unaffected.
  • An unparseable Date header crashed toSummary() for the whole folder. imapflow leaves envelope.date as the raw header string (not an Invalid Date) when it can't parse it; calling .toISOString() on that unconditionally threw, aborting getEmails/searchEmails/sync/getEmailById for every message in the folder over one bad message.
  • Multi-word indexed search returned 0 results when the words weren't adjacent. The SQL/FTS5 layer correctly ANDs each word as its own term, but the post-filter required the entire query as one literal substring — dropping any match whose words were merely out of order or separated by other words.
  • dateFrom was compared as a raw string in the SQL candidate pre-filter, unlike dateTo which was already normalized — a dateFrom with a timezone offset, a bare date, or an English date string could silently exclude matching messages via a wrong lexicographic comparison.
  • isHtml:true sent raw, pre-sanitization HTML as the text/plain part of the message — content the HTML sanitizer had just stripped (script tags, javascript: URIs) still reached plain-text-preferring clients intact.
  • A CLI boolean flag placed before a positional argument swallowed it (search --json invoice dropped the query entirely) — the parser had no notion of which flags are boolean.
  • getThreads({query}) built partial or wrongly-excluded threads. A query matching only a reference chain's root (which has no persisted thread_id and no reference headers of its own) built a thread from the root alone; fixing that then surfaced that the outer filter checked only the thread's latest-message subject, wrongly excluding threads whose matching message wasn't the most recent one.
  • Folder names containing % or , broke indexing and folder resolution. A bare % in a folder/label name crashed decodeURIComponent() inside recordSnapshot(), rolling back the entire index snapshot; a folder name containing a comma was always split as a multi-folder list instead of resolving to itself.

v2.0.6

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Real-mailbox verification of v2.0.5 against a live 57k-message, 4.6k-thread Proton account (rather than mocks).

Fixed

  • get_follow_up_candidates/get_actionable_threads/get_inbox_digest's staleAwaitingYou classified nearly every automated notification as "pending on you" forever. actionableThreadScore() decided pendingOn purely from whether the latest message was outgoing — a one-way automated message (auction/shipping/no-reply notifications) is never replied to and never ages out, so it counted as awaiting-your-reply indefinitely. Reproduced live: 49,026 of ~57,000 threads (including 20-year-old Allegro auction notifications) were flagged pendingOn: "you", making the feature's output effectively noise. Added a local-part heuristic (no-reply/notification/mailer-daemon/etc. senders) to classify these as "unknown" instead of "you".

v2.0.5

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

A self-initiated adversarial review round, matching the methodology of the four external reviews that preceded it (real reproductions against compiled code, not code reading): 5 parallel audits each writing and running actual exploit scripts against dist/, covering claim/lock state machines, UIDVALIDITY and account-identity call-site completeness, bulk/batch operation consistency, local-index migration/capping, and a fresh sweep of previously-unreviewed files. Ten confirmed findings, fixed and verified with new regression tests (264 → 294).

Fixed — Security

  • clear() on LocalIndexService and DraftStoreService (the former wired to the live clear_index tool) completely bypassed account-identity isolation. Every other method on both classes gates on ensureAccountIdentityMatches() before touching disk; clear() called rm() directly. Reproduced: a fresh service instance for account B, with clear() as its very first call, deleted account A's entire index with no error and no check ever having run. This is the most severe finding of this round — a live, zero-friction path to destroying another account's data.
  • saveAttachment/saveAttachments (no explicit outputPath) never checked account identity, silently writing attachment content into whatever dataDir was configured regardless of which account it belonged to — SimpleIMAPService was the one service never wired into the account-isolation guard added in 2.0.2.
  • export_email bypassed the round-4 UIDVALIDITY fix entirely, doing its own raw fetch instead of routing through the now-protected read path — a stale-generation id silently exported a completely different message's raw content to disk with no error.

Fixed — Data integrity

  • bulkMove never received the resolve-once/lock-scoped-recheck fix its three siblings (bulkDelete/bulkUpdateFlags/bulkUpdateLabels) already had, despite being flagged as having "the identical gap" in two prior rounds — confirmed independently by three separate review passes this round. Reproduced both halves: a batch-size limit silently bypassed via double resolution, and a stale-generation move executing unchecked.
  • moveThread/deleteThread/flagThread had the identical missing-generation-check gap as bulkMove — a code path no prior round had examined.
  • batch_email_action/apply_thread_action had no batch-size limit at all, unlike every bulk_* tool — an arbitrarily large emailIds array was processed in full with no safety cap.
  • schedule_draft's duplicate-scheduling guard was a non-atomic check-then-write, letting two concurrent calls for the same draft both succeed and create two independent pending records. checkDue()'s existing atomic draft-claim prevented an actual double send, but the loser was left with a misleading "failed" entry blaming a send_draft call that never happened. The dedupe check is now atomic, inside the same lock as the write.
  • Nearly every loadSnapshot()-based reader still silently truncated at 5,000 messages mailbox-wide — only getThreads/getThreadById had been fixed for this in earlier rounds. getFollowUpCandidates was the worst-affected: its entire purpose is finding old threads, but its snapshot specifically excluded anything beyond the newest 5,000 messages, making it structurally incapable of ever surfacing an old candidate in a mailbox with more than 5,000 recent messages. Also fixed: getActionableThreads, getInboxDigest's stale-detection section, findDocumentThreads, getMeetingPrep, getLabels' folder counts, and search()'s threadId path.
  • The 2.0.4 index-migration fix only checked the immediately-prior 3-field id format, missing the even older 2-field (pre-checksum) format — a message still stored under the oldest shape could still end up duplicated after the format transition.

Fixed — Correctness

  • buildMailOptions could silently send a completely empty-body email when HTML sanitization stripped a body down to nothing (e.g. content that was only a <script> tag) — now throws before ever reaching the SMTP transport.

v2.0.4

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Six findings (5 P1, 1 P2) from a fourth independent external review, fixed and verified with new regression tests (241 → 264). All are edge cases in the UIDVALIDITY-safe id scheme and send-claim mechanism landed in 2.0.3 — integration gaps between that new format/mechanism and the existing index, CLI, bulk operations, and delivery queue.

Fixed

  • Indexing the same message under the old and new id formats created a duplicate row. The messages table's primary key is the full id string, which changed for every message once ids started embedding UIDVALIDITY — a message already indexed under the pre-2.0.3 format got a second row once a normal sync produced its id in the new format, inflating storedMessageCount and letting search/dedup arbitrarily surface the stale old row. Reconciled via a single indexed lookup per upsert (not a table scan), preserving previously-captured content across the transition.
  • UIDVALIDITY protection was opt-in per caller instead of intrinsic to the id. deleteEmail and 5 sibling mutation methods discarded the id's own parsed generation and relied entirely on a separate, external parameter for the actual check — any caller that didn't explicitly pass it (all of src/cli.ts's shortcuts did not) got zero protection even for an id that itself encoded a valid, checkable generation. All 6 methods now derive their expected generation from the id itself by default.
  • Reading a stale id silently returned a different message's content under a freshly-relabeled new id. getParsedMailDetail (backing get_email_by_id, shared by quote/forward/reply content reads) deliberately enforced nothing — a documented but unenforced risk. Now enforces the same generation check every mutation already does.
  • Bulk operations lost the expected generation between id resolution and the actual mutation. bulkDelete/bulkUpdateFlags/bulkUpdateLabels accepted pre-resolved UIDs but never re-verified the generation those UIDs were resolved under inside the mailbox lock the real mutation runs under — only at resolution time, before that lock was even acquired. A generation change in that window meant resolved UIDs got mutated under a different generation with no re-check. Now re-verified inside the same lock as the mutation itself.
  • A draft-store finalization failure after a successful queued send re-unlocked the draft for resending. checkDue()'s single try/catch spanned the SMTP call, the queue-record write, and the draft's own markSent() — if markSent() failed independently after SMTP had already succeeded, the catch treated it as a delivery failure and reverted the draft's claim, and double-counted the item as both sent and failed. markSent() failure is now handled independently (retried, then left in a non-resendable state rather than reverted) and never reaches the delivery-failure path.
  • A small search result limit caused a cascade of single-message FETCH calls. The local-filter search path used the caller's result limit directly as the network batch size — limit:1 with no matches issued one IMAP command per candidate. Batch size is now decoupled from result count, and hasAttachment reuses data already fetched in an earlier pass instead of re-fetching.

v2.0.3

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Six findings (3 P1, 3 P2) from a third independent external review, fixed and verified with new regression tests (215 → 241). Also closes the UIDVALIDITY-unsafe email ID limitation deferred in [2.0.2] — see below.

Fixed

  • The email ID scheme now protects against a UIDVALIDITY (mailbox generation) change. A stale id issued before a full mailbox recreation could previously act on whatever different message now occupies that UID. The id format optionally embeds the mailbox's UIDVALIDITY as a fourth field; an id without one (every id issued before this release) still parses and works exactly as before — unverifiable, not blocked. Wired into every single-message mutation (delete, move, archive, trash, restore, mark read, star, update flags/labels) and into bulk operations, which now exclude a stale-generation id from a batch instead of failing the whole batch.
  • Scheduled send and manual send_draft could still both deliver the same draft. The delivery queue claimed its own record before calling SMTP, but only claimed the source draft after SMTP had already succeeded — a concurrent manual send_draft call could claim and send during that window. The draft is now claimed before SMTP in both paths, sharing one claim mechanism.
  • An audit-log write failure after a successful send caused a duplicate resend. send_draft ran SMTP through the same wrapper that also writes the success audit record — if that write failed (e.g. disk full) after SMTP had already succeeded, the surrounding error handler reverted the draft's claim, making an already-delivered draft resendable. SMTP's outcome is now tracked independently of the audit write; a post-success audit failure is logged but never reverts a successful send.
  • get_audit_logs bypassed the account-isolation guard added in 2.0.2. AuditService was the one store missed when that system was added — two accounts sharing a data directory let one read the other's full audit history, including tool inputs/outputs. Now wired in like every other store.
  • Concurrent first-time account.json initialization had a race. A fixed temp filename let concurrent callers' renames interfere with each other, and the read-check-write sequence had no lock — two different accounts racing to initialize the same fresh data directory had no serialization point, defeating the very mismatch detection this system exists for. Now uses a unique temp filename per call and the existing cross-process file lock, re-reading the marker after acquiring it.
  • Filtering getThreads by query/folder/label could change a thread's identity and drop messages, and a References/In-Reply-To-grouped ("fallback") thread entirely outside the newest 5,000 indexed messages remained unreachable via getThreadById even after the 2.0.2 fix (which only covered natively-threaded messages). Both now resolve against the same uncapped source of truth as native threads.
  • Live IMAP search applied local-only filters (hasAttachment, attachmentName, label, threadId, senderDomain, mailboxRole) after limiting to the newest N candidates, silently dropping a genuinely matching older message that wasn't among the newest N by date. Local filters now apply during a bounded, newest-first batch walk instead of after a fixed cutoff; the common case with no local-only filter is unaffected.

v2.0.2

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Ten findings (6 P1, 4 P2) plus a performance issue and two static-analysis notes from a second, independent external review, fixed and verified with new regression tests (197 → 215). One P1 (a UIDVALIDITY-unsafe email ID scheme) is deliberately deferred — see "Known limitation" below.

Fixed — Security

  • send_test_email bypassed destructive confirmation and PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF — unlike every other outbound-send path, it accepted any recipient and free-text body with no confirmation and no self-address enforcement.
  • A confirmed-alive process's file lock could still be stolen after 30 seconds. isStale() checked PID liveness first, but on a confirmed-alive result fell through to the plain age check anyway — a legitimately slow holder (long critical section, or resuming from sleep) could have its lock stolen out from under it, reintroducing the exact lost-update race the lock exists to prevent.
  • guardAttachmentOutputPath's containment check still hardcoded / while its own ENOENT fallback branch two lines above it already correctly used the platform path separator — a valid Windows path inside the allowed directory could be rejected as escaping it.
  • move_email/bulk_move bypassed the per-action allowlist, checking only read-only mode.

Fixed — Data integrity

  • Switching Proton accounts with the same PROTONMAIL_DATA_DIR exposed the previous account's data. Nothing checked whether the on-disk SQLite index, delivery queue, snooze, draft, or template store actually belonged to the currently-configured account — reproduced live: a second instance read a private phrase from a different account's index, and handed a different account's still-pending queued send to the wrong SMTP transport. Now writes and verifies a small account-identity marker before any store is opened, refusing with a clear error on mismatch (existing pre-fix data adopts the current account as authoritative going forward — this protects future opens, not a pre-existing collision).
  • send_draft could deliver the same draft twice when called concurrently — no atomic claim existed between reading draft.status and the final markSent write. A scheduled send that already fired also left the draft's own status stuck at "draft" forever, so a later manual send_draft call passed every guard and delivered a genuine second copy. Both paths now share one atomic claim (draft → sending → sent).
  • bulk_delete/bulk_update_flags/bulk_update_labels's batch-size limit was validated against a different set than what actually executed — a match-based bulk operation resolved its criteria twice (once for the size check, once to execute), and the mailbox could change between the two IMAP round trips. Now resolves to a concrete UID set exactly once and executes against that same set.
  • Default (no explicit outputPath) attachment saves silently overwrote same-named files — two attachments sharing a filename, in one message or across separate saves, clobbered each other while the tool still reported both as successfully saved. Now uses atomic exclusive file creation with a numeric-suffix fallback on collision.
  • Threads beyond the first 5,000 indexed messages silently disappeared from getThreads/getThreadById, since both built their view from a capped 5,000-message snapshot — a query that plain search() still found correctly returned empty, and a previously-valid threadId could throw "Thread not found" once the index grew past the cap. Thread lookup and filtered search now query SQLite directly, unbounded by the cap.
  • getSyncCheckpointMap/getStatus deserialized up to 5,000 message rows just to read sync checkpoints or folder metadata — measured at 5,000 needless calls per checkpoint read. Both now query only what they need.

Known limitation (tracked, deliberately not fixed this release — needs dedicated design work)

  • The email ID scheme (folder::uid::checksum) has no protection against a UIDVALIDITY change. After a mailbox generation change (full recreation, some migration scenarios), an old, checksum-valid ID for a UID can silently resolve to a completely different message now occupying that UID. assertMailboxUidValidity already exists and works correctly when given an expected value, but nothing currently supplies one. A fix requires extending the ID format (with a documented backward-compatible parse path for existing IDs) and threading the expected value through every single-message and bulk mutation — a real design task, not a surgical patch, and deliberately not forced through under time pressure this release.

v2.0.1

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Nine findings from an independent external code review of v2.0.0 (5 P1, 4 P2), fixed and verified with 15 new regression tests (180 → 195).

Fixed — Security

  • batch_email_action/apply_thread_action could permanently delete messages while bypassing confirmDestructivedelete_email already required confirmed:true for a permanent delete, but the batch and thread-scoped delete paths dispatched straight to the same underlying deletion without that check.
  • move_email/bulk_move bypassed the per-action allowlist (PROTONMAIL_ALLOWED_ACTIONS), checking only read-only mode — an account restricted to e.g. ["mark_read"] could still move any message anywhere, including to Trash.
  • A pending snooze could still move mail after a restart into read-only mode. SnoozeService.wake() had no fire-time runtime-policy recheck, unlike DeliveryQueueService's equivalent send-time check — a snooze created while writes were allowed would still execute post-restart even if the server came back up read-only.

Fixed — Data integrity

  • A flags-only (metadata-only) sync could silently remove a message's body from full-text search. The FTS index was deleted and reinserted using the incoming (empty) preview/attachment text instead of the merged value the messages table's own COALESCE had just preserved — search could go from matching to zero results even though the stored row was intact.
  • sync_emails({full:true}) permanently stopped discovering new mail once a folder finished backfilling to UID 1 — exactly the scenario from this project's own from-scratch Archive backfill. Now tops up with a bounded fetch of anything newer than the last known top once backfill completes.
  • Concurrent snooze wakes (e.g. a timer firing while a manual cancel is in flight) could both issue the same IMAP move. Only the caller that actually wins the pending→waking claim now proceeds to move mail; a losing caller waits for that outcome instead of issuing a second network call.
  • Starting a second server instance against the same data directory could corrupt the first instance's live in-flight send or wake, marking an active send failed or resetting an active wake to pending even though the owning process was still alive and about to complete it. Both queues now stamp the claiming process's PID and only reclaim a record whose owner is confirmed dead (reusing the same liveness check file-lock.ts already uses for stale-lock detection).
  • Syncing a folder the server reports as genuinely empty (exists === 0) never removed that folder's previously-indexed messages, since cleanup only ran for a fetched UID range and the "empty" strategy fetches none. Distinguished from a merely-ambiguous "no known top UID" case so a connection error can never be mistaken for a real empty-mailbox observation.
  • Incremental sync ignored its own per-folder fetch limit on a large backlog. After a long gap offline or a large import, the incremental planner could plan a single fetch spanning the entire gap (e.g. UID 1000 to a current top of 100000) instead of bounding it — now uses the same bounded-window/durable-cursor pattern as full:true backfill.

Changed

  • Declared minimum Node version corrected from 18 to 20, matching better-sqlite3's actual supported range and the CI test matrix.

v2.0.0

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Major version bump: the full-mailbox backfill mechanism was broken through v1.19.5 and is fixed here, then validated live against a real account with 57,000+ indexed messages across 62 folders/labels — including a from-scratch, UID-window-by-window backfill of a 22,836-message Archive folder to completion, with zero data loss across restarts, transient IMAP disconnects, and request timeouts. This is the first release where sync_emails({full:true}) on a large pre-existing folder actually works end-to-end rather than silently looping on the newest window or deleting older mail.

Fixed

  • Full sync could never backfill folder history, and silently deleted it. full:true always fetched the newest N UIDs from scratch on every call, ignoring any previous progress — and expunge-detection compared each freshly-fetched window against every stored message in the folder, so each new backfill window deleted everything outside itself. Repeated full:true calls converged to only the last-fetched window, making a large pre-existing folder (tens of thousands of messages) permanently unindexable beyond its newest slice. Now tracks a backfilledToUid checkpoint and walks the mailbox backward one window at a time, restarting cleanly if UIDVALIDITY changes, with expunge-detection scoped strictly to the UID range just re-scanned.
  • backfilledToUid read back from SQLite as NULL broke the very first backfill call after a restart. NULL mapped to JavaScript null instead of undefined, and null <= 1 evaluates to true — so the very first post-restart backfill call looked like backfill was already complete and fetched nothing.
  • get_index_status reported storedMessageCount/dedupedMessageCount capped at 5000 regardless of actual index size — it read off the thread-builder snapshot (deliberately capped for performance) instead of a real COUNT(*). A 45,000-message index reported exactly 5000 stored messages.
  • sync_emails silently ignored its own folder/full/limitPerFolder/includeAttachmentText arguments and always ran whatever the background auto-sync was already configured for — calling sync_emails({folder:"Archive", full:true}) had no effect at all.
  • bulk_update_labels (and other bulk operations) failed completely on a single transient IMAP/IDLE disconnect that bulk_delete recovered from automatically — the UID-matching search path inside resolveUidsForBulkOp had no reconnect-and-retry, unlike every other mutation.

v1.19.5

Choose a tag to compare

@googlarz googlarz released this 09 Sep 05:32

Follow-up fixes from a final hacker/security/performance/senior-dev review pass of the v1.19.4 changes.

Fixed

  • The v1.19.4 SQLite growth fix (auto_vacuum = INCREMENTAL) did nothing on any real upgrade — SQLite silently ignores that pragma on an already-populated database, so every existing install kept growing unboundedly exactly as before. Now detects when the pragma didn't take effect and forces conversion with a one-time VACUUM.
  • pruneSentDrafts had no fallback to createdAt when sentAt was missing, unlike the equivalent pruning in delivery-queue-service.ts/snooze-service.ts — a future migration/import producing a "sent" draft without sentAt would never be pruned.
  • TOCTOU gap in attachment/export path validation: guardAttachmentOutputPath validated a path via realpathSync but returned void, so callers re-derived and wrote through the original, non-realpath'd path — a symlink swapped in after validation could redirect the write outside the allowed directory. Callers now write through the already-validated real path.
  • Audit log rotation kept only one archive generation, so a burst of ordinary tool calls forcing two rotations could permanently evict a specific targeted historical entry. Now keeps two generations (.1, .2), doubling that cost.