Releases: googlarz/proton-mail-bridge-client
Release list
v2.0.8
Fixed
- CI's
npm audit --audit-level=highstarted failing on a newly-published high-severity advisory against nodemailer <=9.1.0 (affectingresolveContent()'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 directnodemailerdependency to^9.1.1(same major, no API change) andmailparserpicked up its own patched nested nodemailer vianpm audit fix.hono(a transitive dependency of@modelcontextprotocol/sdk, moderate severity) was also resolved by the samenpm audit fixrun. No source changes;npm auditnow reports 0 vulnerabilities.
v2.0.7
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_draftscould permanently brick the MCP session on a large attachment. It has no filter and returns every draft unconditionally;DraftRecord.attachmentscarries 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) inlist_drafts;get_draftis unaffected.- An unparseable
Dateheader crashedtoSummary()for the whole folder. imapflow leavesenvelope.dateas the raw header string (not anInvalid Date) when it can't parse it; calling.toISOString()on that unconditionally threw, abortinggetEmails/searchEmails/sync/getEmailByIdfor 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.
dateFromwas compared as a raw string in the SQL candidate pre-filter, unlikedateTowhich was already normalized — adateFromwith a timezone offset, a bare date, or an English date string could silently exclude matching messages via a wrong lexicographic comparison.isHtml:truesent 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 invoicedropped 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 crasheddecodeURIComponent()insiderecordSnapshot(), 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
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()decidedpendingOnpurely 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 flaggedpendingOn: "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
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()onLocalIndexServiceandDraftStoreService(the former wired to the liveclear_indextool) completely bypassed account-identity isolation. Every other method on both classes gates onensureAccountIdentityMatches()before touching disk;clear()calledrm()directly. Reproduced: a fresh service instance for account B, withclear()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 explicitoutputPath) never checked account identity, silently writing attachment content into whateverdataDirwas configured regardless of which account it belonged to —SimpleIMAPServicewas the one service never wired into the account-isolation guard added in 2.0.2.export_emailbypassed 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
bulkMovenever 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/flagThreadhad the identical missing-generation-check gap asbulkMove— a code path no prior round had examined.batch_email_action/apply_thread_actionhad no batch-size limit at all, unlike everybulk_*tool — an arbitrarily largeemailIdsarray 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 asend_draftcall 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 — onlygetThreads/getThreadByIdhad been fixed for this in earlier rounds.getFollowUpCandidateswas 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, andsearch()'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
buildMailOptionscould 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
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
messagestable'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, inflatingstoredMessageCountand 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.
deleteEmailand 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 ofsrc/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(backingget_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/bulkUpdateLabelsaccepted 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 ownmarkSent()— ifmarkSent()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
limitcaused 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:1with no matches issued one IMAP command per candidate. Batch size is now decoupled from result count, andhasAttachmentreuses data already fetched in an earlier pass instead of re-fetching.
v2.0.3
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_draftcould 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 manualsend_draftcall 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_draftran 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_logsbypassed the account-isolation guard added in 2.0.2.AuditServicewas 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.jsoninitialization 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
getThreadsby 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 viagetThreadByIdeven 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
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_emailbypassed destructive confirmation andPROTONMAIL_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_movebypassed the per-action allowlist, checking only read-only mode.
Fixed — Data integrity
- Switching Proton accounts with the same
PROTONMAIL_DATA_DIRexposed 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_draftcould deliver the same draft twice when called concurrently — no atomic claim existed between readingdraft.statusand the finalmarkSentwrite. A scheduled send that already fired also left the draft's own status stuck at"draft"forever, so a later manualsend_draftcall 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 plainsearch()still found correctly returned empty, and a previously-validthreadIdcould 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/getStatusdeserialized 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.assertMailboxUidValidityalready 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
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_actioncould permanently delete messages while bypassingconfirmDestructive—delete_emailalready requiredconfirmed:truefor a permanent delete, but the batch and thread-scoped delete paths dispatched straight to the same underlying deletion without that check.move_email/bulk_movebypassed 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, unlikeDeliveryQueueService'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
messagestable's ownCOALESCEhad 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
failedor resetting an active wake topendingeven 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 checkfile-lock.tsalready 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:truebackfill.
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
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:truealways 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. Repeatedfull:truecalls 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 abackfilledToUidcheckpoint and walks the mailbox backward one window at a time, restarting cleanly ifUIDVALIDITYchanges, with expunge-detection scoped strictly to the UID range just re-scanned. backfilledToUidread back from SQLite asNULLbroke the very first backfill call after a restart.NULLmapped to JavaScriptnullinstead ofundefined, andnull <= 1evaluates totrue— so the very first post-restart backfill call looked like backfill was already complete and fetched nothing.get_index_statusreportedstoredMessageCount/dedupedMessageCountcapped at 5000 regardless of actual index size — it read off the thread-builder snapshot (deliberately capped for performance) instead of a realCOUNT(*). A 45,000-message index reported exactly 5000 stored messages.sync_emailssilently ignored its ownfolder/full/limitPerFolder/includeAttachmentTextarguments and always ran whatever the background auto-sync was already configured for — callingsync_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 thatbulk_deleterecovered from automatically — the UID-matching search path insideresolveUidsForBulkOphad no reconnect-and-retry, unlike every other mutation.
v1.19.5
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-timeVACUUM. pruneSentDraftshad no fallback tocreatedAtwhensentAtwas missing, unlike the equivalent pruning indelivery-queue-service.ts/snooze-service.ts— a future migration/import producing a "sent" draft withoutsentAtwould never be pruned.- TOCTOU gap in attachment/export path validation:
guardAttachmentOutputPathvalidated a path viarealpathSyncbut returnedvoid, 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.