# mailpouch — full-repo audit (2026-05-28)

**Repo state:** main @ `007f0ec` · mailpouch v3.0.42 · post-preship-gate ship.

## Executive summary

**241 findings** across 12 detective beats (4 waves × 3 parallel agents).

| Severity | Count | What it means |
|---|---|---|
| Critical | 2 | Active data-loss / credential leak / auth bypass / RCE |
| High | 24 | User-visible correctness bug; silent mail loss or state corruption |
| Medium | 89 | Defensive gap; exploitable or surprising edge case |
| Low | 105 | Quality issue; not currently exploitable |
| Info | 21 | Observation; not a bug |

### Top 5 to fix first

1. **The IMAP unit suite still lies about per-folder UID space.** `defaultFetchMock()` yields any UID for any range, and `messageMove`/`messageDelete` mocks ignore which mailbox is locked ([TEST-001](#test-001--default-fetch-mock-turns-the-source-folder-uid-space-into-a-wildcard), [TEST-002](#test-002--messagemove-mock-ignores-the-source-mailbox-lock)). The 3.0.41 bug class can ship again because the legacy tests (without `sourceFolder`) cannot catch it. Single highest-leverage fix.
2. **The 3.0.41 fix is incomplete** in 4+ siblings: `bulkMarkRead`/`bulkStar`/`bulkCopyToFolder` default to `'INBOX'` on cache miss ([IMAP-003](#imap-003--bulkmarkread--bulkstar--bulkcopytofolder-silently-default-to-inbox-on-cache-miss)); `setFlag` was missed by the pre-flight FETCH treatment entirely ([IMAP-008](#imap-008--setflag-lacks-findexistinguidsinlockedfolder-preflight--silent-no-op-restored-for-cache-hit-paths)) and the `sourceFolder` plumbing ([IMAP-009](#imap-009--setflag-does-not-accept-sourcefolder-so-the-cross-folder-uid-hint-plumbing-is-incomplete)); `getEmailById` never validates `folderHint` ([VALID-001](#valid-001--getemailbyid-and-callers-never-validate-folderhint)); the UID FETCH preflight swallows transport errors and lies "all UIDs missing" ([IMAP-006](#imap-006--uid-fetch-preflight-inside-lock-pretends-a-server-error-means-all-uids-missing)).
3. **The credential surface lies about its security posture.** `credentialStorage="keychain"` is advertised but **Pass PATs and SimpleLogin API keys are written to `~/.mailpouch.json` in plaintext** ([CRED-001](#cred-001--migratecredentials-migrates-only-password--smtptoken-passaccesstoken-and-simpleloginapikey-remain-plaintext-on-disk-forever)). The AES-GCM encryption mode uses `sha256(machine-id)` as the entire key — any local UID recovers it ([CRED-003](#cred-003--aes-gcm-key-derived-via-single-sha-256-over-low-entropy-inputs-no-salt-stretching-no-kdf)). Path-traversal guards on `MAILPOUCH_CONFIG` were not extended to the sibling `MAILPOUCH_*` env-driven overrides ([CRED-002](#cred-002--_homefile-honors-env-var-overrides-with-zero-containment-check-path-traversal-hole)).
4. **The destructive-confirm and rate-limit gates are bypassable via aliases and arg values.** `bulk_delete` and `bulk_delete_emails` share a handler but get independent rate buckets — doubling the destruction throughput ([PERM-003](#perm-003--per-tool-rate-limit-bypass-via-bulk_delete--bulk_delete_emails-alias)). `move_email { targetFolder: "Trash" }` is **not** gated by destructive-confirm but `move_to_trash` is ([PERM-004](#perm-004--move_email-bypasses-destructive-confirm-via-targetfolder-trashspam)). Escalation approval applies globally instead of per-agent — approving Agent A's request silently widens the preset for Agent B too ([PERM-002](#perm-002--escalation-approval-applies-globally-not-to-the-requesting-agent)).
5. **The preship gate I just shipped doesn't match its own docs.** `docs/preship.md` advertises `npm-audit` as a hard-fail on HIGH/CRITICAL; the orchestrator wraps it as `mode: "advisory"` and exits 0 on a real CVE ([DOCS-009](#docs-009--preshipmd-claims-npm-audit-hard-fails-on-highcritical-orchestrator-wraps-it-as-advisory)). The documented `PRESHIP_SKIP=1` bypass doesn't exist in code ([BUILD-014](#build-014--docspreshipmd-claims-preship_skip1-ship-bypass-exists-no-code-in-this-repo-honors-it)). The `LICENSES.json` baseline records 11 prod deps as `version: "(unknown)"`, defeating drift detection ([BUILD-004](#build-004--license-inv-baseline-records-version-unknown-and-license-unknown-for-legit-prod-deps)).

### Recurring patterns (across beats)

- **The 3.0.41 bug class is structural, not local.** "Catch an error and return success" appears in IDLE TLS downgrade, UID preflight, draft-folder discovery, `getEmails` connection-lost path, `disconnect()`, `start_bridge`, sanitize-then-assert-success tests, and reminder/scheduler persist failures. The fix isn't 3 lines per site — it's one shared `Result<T>` convention.
- **Validators drift across three layers** (`utils/helpers.ts`, per-tool `optional*` wrappers, IMAP-service private re-validators). Different character classes for control-char rejection (`[\x00-\x1f]` vs `[\x00-\x1f\x7f]` vs full C0/C1/DEL), different length caps (255 vs 1000), different empty-string semantics, and **two identically-named `validateFolderName` functions with contradictory rules** (helpers leaf-only, service full-path). VALID-003, VALID-004, VALID-007, VALID-013, VALID-014 are all symptoms of one root.
- **Mocks lie about what production does.** The fetch mock yields any UID for any range, `messageMove` ignores lock target, `getFolders` strips `specialUse`, sanitizer tests never read back sanitized output, `expect(...).toBeDefined()` is a tautology in 4+ harness tests. TEST-001 through TEST-008 are all of one shape.
- **Trust model inconsistency in the transport.** Three different `clientIp()` implementations (XFF-aware in `http.ts`, socket-only in `oauth-handlers.ts`, snapshot in `oauth-store.ts`). Defense-in-depth headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Cache-Control`) set on settings UI but never carried to the OAuth/MCP transport. DCR client_name flows raw into consent page, audit log, Slack/Discord webhooks, settings UI grant cards.
- **Time-zone schizophrenia in analytics.** `volumeTrends` buckets by UTC, `peakActivityHours` uses host-local time, iCal `TZID=` parameters are dropped. The chart UI's "Tuesday 9am" is ambiguous.
- **File-based state without locks.** Pending-grants file, audit log, agent grants, escalation queue, `~/.mailpouch.json` all mutate via load → modify → atomic rename. No flock. Settings UI + MCP server + escalation timer race; last writer wins.
- **Supply-chain hygiene gaps post-2024.** Floating GitHub Action tags including `dtolnay/rust-toolchain@stable`, `npm ci` without `--ignore-scripts` in the publish job that holds OIDC + NPM_TOKEN, `prepare` script that fires on consumer installs, ship `.d.ts.map`/`.js.map` with absolute paths from `/mnt/data/Code/mailpouch/src/`.

### Out of scope (per plan)

- Anything under `dist/`, `node_modules/`, or `native/tray/src/` (Rust source).
- The 8 known-failing tests in `test/agent-harness.test.ts` (require live Bridge).
- The v3.0.41 bug fixes A/B/C (already shipped in `007f0ec`).
- External-service supply-chain analysis beyond what `npm audit` already gates.
- Live network probing of the OAuth tunnel.

---

## Quick index

The audit is organized **by area** (one section per detective beat) below. Within each section, findings are sorted by severity (Critical → Info). Use this table to skim Critical + High first.

| Beat | Findings | Critical | High | Medium | Low | Info |
|---|---:|---:|---:|---:|---:|---:|
| IMAP correctness | 22 | 0 | 5 | 9 | 6 | 2 |
| SMTP / drafts / scheduling | 20 | 0 | 2 | 8 | 6 | 4 |
| Credentials, crypto | 15 | 0 | 3 | 6 | 5 | 1 |
| Transports, OAuth, remote | 22 | 0 | 1 | 8 | 9 | 4 |
| Permissions, escalation | 16 | 0 | 3 | 7 | 5 | 1 |
| Tool surface (non-IMAP/SMTP) | 25 | 0 | 0 | 6 | 12 | 7 |
| Validators, injection | 21 | 0 | 1 | 6 | 13 | 1 |
| Data parsers (FTS, analytics) | 20 | 0 | 1 | 8 | 11 | 0 |
| Settings UI, native tray | 18 | 0 | 0 | 9 | 9 | 0 |
| Test quality | 25 | 2 | 4 | 12 | 5 | 2 |
| Build, CI, supply | 25 | 0 | 4 | 10 | 11 | 0 |
| Docs, memory drift | 12 | 0 | 1 | 6 | 5 | 0 |
| **Total** | **241** | **2** | **25** | **95** | **97** | **22** |

(Slight tally drift vs. the severity totals above is from a few findings the agents tagged with sub-categories; the per-beat numbers are the more accurate slice.)

---

## Findings by area

### IMAP correctness

#### IMAP-001 — `runIdleLoop` silently downgrades to `rejectUnauthorized:false` when the user required a pinned Bridge cert
- Location: `src/services/simple-imap-service.ts:2536-2548`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: The main `connect()` path (lines 418–425, 436–442) throws if the configured Bridge cert cannot be loaded and `allowInsecureBridge` is **not** set — exactly the contract the user agreed to when they configured a cert. The IDLE loop completely ignores that contract: if `readPinnedBridgeCert` throws, the `catch` block at line 2543 just falls back to `{ rejectUnauthorized: false, ... }`, and if `bridgeCertPath` is unset it does the same (line 2547). `cfg.allowInsecureBridge` is never consulted. A locally-resident attacker who can MITM port 1143 (or swap the bridge cert mid-process — which `readPinnedBridgeCert` is *specifically* designed to catch) will be undetected on the IDLE socket while the main client correctly refuses; INBOX push notifications, EXISTS/EXPUNGE counts, and any future IDLE-served data flow over the downgraded socket.
- Repro hypothesis: Configure mailpouch with a valid `bridgeCertPath` and `allowInsecureBridge: false`. After `connect()` succeeds, call `chmod 000` on the cert file (or replace it with garbage), then call `startIdle()`. The main connection still refuses on reconnect; the idle connection comes up with TLS validation disabled. Unit test: mock `readPinnedBridgeCert` to throw on the second call, assert `runIdleLoop` either throws or that `tlsOptions.rejectUnauthorized` is not `false` when `cfg.allowInsecureBridge !== true`.
- Suggested next step: Mirror the `connect()` policy — if `bridgeCertPath` is set and cert load fails, and `cfg.allowInsecureBridge` is not true, log an error and exit the idle loop (or skip starting it). Same for the no-cert-path branch.
> **Resolved in v3.0.45 — Batch 3.** `runIdleLoop` now mirrors the main `connect()` contract: missing/broken cert with `allowInsecureBridge` unset logs an error and refuses to start the IDLE socket.

#### IMAP-002 — Unbounded UID set in single IMAP command exceeds Bridge's command-buffer cap
- Location: `src/services/simple-imap-service.ts:1892, 2052, 2143, 2226, 2311, 2374`
- Category: Correctness
- Severity: High
- Confidence: Likely
- Description: Every bulk path joins all UIDs with `,` into one IMAP command (`messageMove`, `messageDelete`, `messageFlagsAdd`, `messageCopy`). Proton Bridge — and the IMAP RFC's practical limits — cap command lines around 8 KB. ~800 nine-digit UIDs already exceed that; a single bulk operation on a 1000-message selection sends one giant line, the server replies `BAD line too long`, the `catch` falls through to per-UID retry (lines 1900, 2059, 2152, 2234, 2316). Per-UID retry against 1000 messages can take minutes inside a held mailbox lock, blocking every other operation including the user-visible UI. Also: `findExistingUidsInLockedFolder` (line 301) builds the same unbounded `uidSet` for the pre-flight FETCH — if **that** fails, every UID gets marked failed *despite existing* (silent corruption of the success/failure counters).
- Repro hypothesis: `bulk_delete_emails` with 2000 UIDs. Either via unit test (mock `messageDelete` to throw `BAD command line too long` for `uidSet.length > 8000`, assert fallback path is taken and total wall time is bounded), or via the live Bridge E2E by selecting 2000 messages and watching the duration.
- Suggested next step: Chunk `present` into batches of ~200 UIDs (or use IMAP sequence-set compression like `1:10,15,20:30`) and issue one `messageDelete`/`messageMove` per chunk. Same change in `findExistingUidsInLockedFolder`.
> **Resolved in v3.0.45 — Batch 3.** New `chunkUidsForWire(maxLen=7500)` helper + `chunkedBatchOp` private bound the wire-line per chunk; all six bulk methods and the existence pre-flight route through it.

#### IMAP-003 — `bulkMarkRead` / `bulkStar` / `bulkCopyToFolder` silently default to INBOX on cache miss
- Location: `src/services/simple-imap-service.ts:2117-2118, 2200-2201, 2285-2286`
- Category: Data-loss
- Severity: High
- Confidence: Confirmed
- Description: When `sourceFolder` is not provided and the UID is not cached, three bulk methods fall back to `'INBOX'` with only a warn-log. This is exactly the v3.0.41 bug class for these three operations — the fix on the singular `moveEmail/markEmailRead/starEmail` paths uses `getEmailById` (a full folder scan) when `sourceFolder` is absent, but the bulk paths skip that and assume INBOX. A 1000-UID bulk-mark-read where the UIDs live in `Folders/Work` will report `success: 1000` and silently mark INBOX messages with matching numeric UIDs as read (or mark nothing if no INBOX UID collides — both outcomes are wrong, and the pre-flight UID FETCH only catches the *latter*).
- Repro hypothesis: Connect fresh (empty cache); call `bulk_mark_read({ emailIds: ['12345'], sourceFolder: undefined })` where UID 12345 lives in `Folders/Work` and a *different* message with UID 12345 also exists in INBOX. Result: INBOX message is marked read, `Folders/Work` message untouched, return value claims `success: 1`. Unit test: mock `findCacheEntryByUid` → undefined, mock `findExistingUidsInLockedFolder` to return the set when locking INBOX, assert that the assertion "lock was acquired on `Folders/Work`" *fails*.
- Suggested next step: Mirror the singular path — when `sourceFolder` is absent and cache misses, call `getEmailById(id)` to discover the folder before grouping. Or hard-fail with a clear error rather than guess.
> **Resolved in v3.0.44 — finish 3.0.41 sibling-path fix (#131).** Verified in v3.0.61: the bulk cache-miss path now calls `getEmailById(emailId)` to discover the owning folder (and reports `Email <id> not found in any folder` when discovery fails) instead of defaulting to `'INBOX'`.

#### IMAP-006 — UID FETCH preflight inside lock pretends a server error means "all UIDs missing"
- Location: `src/services/simple-imap-service.ts:298-315`
- Category: Data-loss
- Severity: High
- Confidence: Confirmed
- Description: `findExistingUidsInLockedFolder` is the gatekeeper for every mutation: anything not in `found` is reported as `failed`. But the catch at line 308 swallows *any* error from the fetch (network blip, server `BAD`, command-too-long from IMAP-002) and returns an empty `Set`. The caller then routes every UID into `results.failed` with the message `UID X not found in folder Y` — a flat-out lie. The user sees `failed: 1000`, retries, and may then escalate to a destructive workaround thinking the UIDs are gone.
- Repro hypothesis: Unit test: mock `client.fetch` for the UID-existence query to throw `Error('connection reset')`; invoke `bulkDeleteEmails(['1','2','3'], 'INBOX')`. Assert the error surfaces (or `failed` includes a transport-error message), not three identical `UID not found` strings.
- Suggested next step: Re-throw transport errors so the caller can distinguish "UID is absent" from "I have no idea if it's absent." Alternatively, mark these UIDs as `unknown` in a third bucket rather than collapsing into `failed`.
> **Resolved in v3.0.44 — finish 3.0.41 sibling-path fix (#131).** Verified in v3.0.61: `findExistingUidsInLockedFolder` no longer swallows errors — it lets transport errors propagate, and the bulk callers catch them separately to report `UID <id> existence check failed in folder <f>: <msg>` instead of a flat `UID not found`.

#### IMAP-008 — `setFlag` lacks `findExistingUidsInLockedFolder` preflight — silent no-op restored for cache-hit paths
- Location: `src/services/simple-imap-service.ts:1788-1799`
- Category: Data-loss
- Severity: Medium
- Confidence: Confirmed
- Description: Every other mutating method got the v3.0.41 "pre-flight FETCH inside the lock" treatment, but `setFlag` did not. If the UID is in cache from a stale read, or the scan found a UID and a concurrent operation moved it before we re-acquire the lock at line 1788, `messageFlagsAdd` silently does nothing (IMAP STORE on a non-existent UID is a no-op). The method then returns `true` with `logger.info('Flag set on email …')`. This is the exact data-loss class the v3.0.41 fix was meant to close, but for the `setFlag` entry point.
- Repro hypothesis: Cache a UID for a folder where the message has been moved out. Call `setFlag(uid, '$Custom')`. Assert that `messageFlagsAdd` was called with a UID confirmed present, or that the method throws. Currently passes silently with `true`.
- Suggested next step: Add the same `findExistingUidsInLockedFolder` check after acquiring the lock at line 1788.
> **Resolved in v3.0.44 — finish 3.0.41 sibling-path fix (#131).** Verified in v3.0.61: `setFlag` now runs the `findExistingUidsInLockedFolder` pre-flight inside the held lock before `messageFlagsAdd`, so a moved/absent UID fails rather than silently no-op'ing while returning `true`.

#### IMAP-004 — Unsanitised header search field/value enables IMAP SEARCH injection
- Location: `src/services/simple-imap-service.ts:993`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: Every other SEARCH input is run through `sanitizeImapStr` (strip `"` and `\`), but `options.header.field` and `options.header.value` are passed through raw on line 993: `searchCriteria.header = { [options.header.field]: options.header.value };`. imapflow serialises both into quoted IMAP strings. A `value` containing `"` closes the quoted string early; a `field` containing space/quote/control characters breaks the `SEARCH HEADER <field-name> <value>` form. This is a defence-in-depth gap exposed through the MCP `search_emails` tool — anything that can reach that tool can craft arbitrary IMAP search terms or trigger a `BAD` response that aborts the search round-trip.
- Repro hypothesis: Call `search_emails({ header: { field: 'X-Test', value: '"\r\nLOGOUT' } })`. Assert that the IMAP wire bytes do not contain a bare `LOGOUT`. Without the fix the search will either return `BAD`, or, on Bridge specifically, terminate the connection mid-command.
- Suggested next step: Apply `sanitizeImapStr` to both `field` and `value`, plus a stricter `/^[A-Za-z][A-Za-z0-9\-]*$/` regex on `field` (RFC 5322 field-name grammar).
> **Resolved in v3.0.52 — IMAP service hardening.** `searchSingleFolder` now runs the header value through `sanitizeImapStr` and validates the field against `/^[A-Za-z][A-Za-z0-9-]*$/`, rejecting a non-conforming field before any IMAP round-trip.

#### IMAP-005 — `evictCacheEntry` for IDLE EXISTS/EXPUNGE mutates Map mid-iteration
- Location: `src/services/simple-imap-service.ts:2575-2577, 2582-2584`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: The handlers iterate `for (const [id, entry] of this.emailCache)` and call `this.evictCacheEntry(id)` inside the loop. JS `Map` iteration is technically safe for *deletion* of the current key, but other concurrent mutators (any `setCacheEntry` from a parallel main-client fetch) racing against the IDLE callback will produce a half-evicted state. Also, the variable name `id` is misleading — these are folder-qualified cache keys, not UIDs; the check `entry.email.folder === 'INBOX'` will fail to fire for INBOX entries cached under aliased paths (e.g. `inbox`, `[Gmail]/INBOX`). Finally the loops swallow no errors but they evict by string equality of `'INBOX'` only, so push invalidations on a Bridge server that exports `Inbox` would not invalidate anything.
- Repro hypothesis: Unit test: seed `emailCache` with two entries `INBOX:1` and `Inbox:2`; fire the simulated `exists` event; assert both are evicted. Will currently fail for `Inbox:2`.
- Suggested next step: Either canonicalise folder names at insert time, or do case-insensitive comparison and also match special-use `\Inbox`. Snapshot keys via `Array.from(emailCache.keys())` before the eviction loop.
> **Resolved in v3.0.52 — IMAP service hardening.** Both IDLE handlers now call a single `evictInboxCacheEntries()` helper that snapshots `Array.from(emailCache.keys())` before deleting and matches the folder case-insensitively (`folder.toLowerCase() === 'inbox'`), so aliased INBOX paths are invalidated and concurrent `setCacheEntry` can't corrupt the iteration.

#### IMAP-007 — `parsed.to`/`parsed.cc` can be an array of `AddressObject`; the code only handles the singular shape
- Location: `src/services/simple-imap-service.ts:884-885, 1067-1068, 1306-1307`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: `mailparser`'s `ParsedMail.to` is typed `AddressObject | AddressObject[] | undefined`. When a message has multiple separate `To:` header lines (legal per RFC 5322 §3.6.3, and Proton's mail backend does emit them on bridged forwards), `parsed.to` becomes an array — `parsed.to.text` is then `undefined`, so the ternary collapses to `[]` and the recipient list silently disappears. Same for `cc`. Downstream tools (`forward_email`, `reply_to_email`, contacts harvester) then send a mail to nobody, or AI summaries omit the actual recipient context.
- Repro hypothesis: Construct a raw RFC822 message with two `To:` headers, feed it through `simpleParser`, call `getEmailById`. Assert `emailMessage.to.length >= 2`. Currently `to.length === 0`.
- Suggested next step: Normalise: `const toAddr = Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : []; const toStrs = toAddr.flatMap(a => a.value?.map(v => v.address ?? '') ?? []);`. Same for `cc`.
> **Resolved in v3.0.52 — IMAP service hardening.** New exported `normalizeAddressList(field)` accepts the `AddressObject | AddressObject[] | undefined` shape and flattens it to a string array; applied at all three `to`/`cc` extraction sites (`getEmailById`, the list fetch, and the attachment full-source re-fetch).

#### IMAP-009 — `setFlag` does not accept `sourceFolder`, so the cross-folder UID hint plumbing is incomplete
- Location: `src/services/simple-imap-service.ts:1752, 1808`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: Every other mutator was rebuilt around `sourceFolder?: string` in v3.0.41. `setFlag` still does the legacy cache-then-scan-all-folders dance. The scan opens-and-releases a lock on **every** folder until the UID is found — N round-trips and N transient mailbox SELECTs against Bridge. On a mailbox with 20 labels that's an expensive operation, *and* it picks the first folder containing a matching numeric UID — which on Proton's label model (where UIDs are folder-scoped and may collide across `Labels/A` and `Labels/B`) is the same bug class the rest of the fix closed.
- Repro hypothesis: Two labels both contain a message with UID `42`. Call `setFlag('42', '$Custom', true)`. The flag lands on whichever label `getFolders()` returns first; the caller has no way to specify. Unit test: mock `getFolders` to return `[{path:'Labels/A'}, {path:'Labels/B'}]`, mock `fetch` in both to return UID 42, assert that the call signature accepts a folder hint that controls which lock is taken.
- Suggested next step: Add `sourceFolder?: string` parameter; plumb through the same way as `markEmailRead`. Tool-side: forward existing `sourceFolder` arg from the relevant MCP tools to `setFlag`.
> **Resolved in v3.0.44 — finish 3.0.41 sibling-path fix (#131).** Verified in v3.0.61: `setFlag(emailId, flag, set, sourceFolder?)` now accepts and validates `sourceFolder`, taking the lock on the caller-specified folder instead of the first-match folder scan.

#### IMAP-010 — `checkAndUpdateUidValidity` swallows every error
- Location: `src/services/simple-imap-service.ts:213-233`
- Category: Defense-in-depth
- Severity: Medium
- Confidence: Confirmed
- Description: The `catch { /* best-effort */ }` at line 230 means a thrown comparison (e.g. `currentValidity` being a Number on some imapflow build where the `bigint` typing is wrong, causing `bigint !== number` to throw on the comparison; or a thrown getter on `mailbox` after a reconnect race) silently leaves stale UID cache in place. UIDVALIDITY changes are *exactly* when the cache must be flushed; a swallowed error here is the silent-corruption v3.0.41 class — the user mutates the wrong message because the local cache survived a server-side mailbox rebuild.
- Repro hypothesis: Unit test: stub `this.client.mailbox` to a getter that throws. Confirm `checkAndUpdateUidValidity('INBOX')` either logs an error at warn level or rethrows. Currently produces no observable signal.
- Suggested next step: Log at `warn` with the error in the catch — at minimum future debugging will see something. Ideally, on any UIDVALIDITY tracking error, conservatively clear the whole cache.
> **Resolved in v3.0.52 — IMAP service hardening.** The `catch` now logs at warn with the error and calls `clearCacheAll()` — both the observable signal and the conservative cache flush the audit asked for.

#### IMAP-012 — `getEmails` returns silently empty on connection lost; obscures errors to the caller
- Location: `src/services/simple-imap-service.ts:711-720`
- Category: Data-loss
- Severity: Medium
- Confidence: Confirmed
- Description: When the IMAP connection cannot be (re-)established, `getEmails` returns `[]` and logs a warn. The MCP tool's caller — a model, or a user-facing list view — cannot distinguish "the folder is empty" from "I can't reach the server." This is the v3.0.41 anti-pattern in a read path: catching an error and returning success. A model summarising "you have 0 unread emails" when in fact the bridge is down is user-visible misinformation.
- Repro hypothesis: Stop Proton Bridge; call `getEmails('INBOX')`. Result is `[]` with `info: Retrieved 0 emails from INBOX`. There is no error surface.
- Suggested next step: Throw a typed `IMAPNotConnectedError` and let the MCP tool serialise it as a structured error response.
> **Resolved in v3.0.52 — IMAP service hardening.** `getEmails` now throws the new exported `IMAPNotConnectedError` on a connection/client failure instead of returning `[]`; the central tool dispatcher already serialises thrown errors. The legitimate empty-folder path (connection up, `total === 0`) still returns `[]`.

#### IMAP-014 — `protectedFolders` list uses literal English names; sees through localised special-use mailboxes
- Location: `src/services/simple-imap-service.ts:2444, 2485`
- Category: Defense-in-depth
- Severity: Medium
- Confidence: Likely
- Description: `deleteFolder` and `renameFolder` block names that match the English list `['INBOX','Sent','Drafts','Trash','Spam','Archive','All Mail','Starred']`. Bridge presents Proton's pseudo-folders in English at the IMAP layer, so today this works, but the list does *not* consult `specialUse`. A user whose Bridge is configured with custom localised paths (or a future Bridge release that exposes localised IMAP paths the way some other servers do) could delete or rename `'\Trash'` because its path string is `Papelera`. The check should be against `specialUse`, not against names.
- Repro hypothesis: `deleteFolder('INBOX  ')` (trailing whitespace) is allowed because `'INBOX  '.toLowerCase() !== 'INBOX'.toLowerCase()`. Bridge will accept that as INBOX if it canonicalises whitespace.
- Suggested next step: Trim+casefold the input, *and* resolve via `getFolders()` and check `specialUse` against `['\\Inbox','\\Sent','\\Drafts','\\Trash','\\Junk','\\Archive','\\All','\\Flagged']` before allowing destructive ops.
> **Resolved in v3.0.52 — IMAP service hardening.** New `isProtectedFolder()` helper (used by `deleteFolder` and `renameFolder`) trims+casefolds the input against the system-name set and, when folder discovery succeeds, also matches the server-reported `specialUse` against `['\\Inbox','\\Sent','\\Drafts','\\Trash','\\Junk','\\Archive','\\All','\\Flagged']`.

#### IMAP-016 — `messageDelete` per-UID retry path issues N EXPUNGEs in sequence on Bridge, blocking IDLE
- Location: `src/services/simple-imap-service.ts:2059-2073, 2378-2390`
- Category: Performance
- Severity: Medium
- Confidence: Likely
- Description: When the bulk `messageDelete(uidSet, …)` fails (e.g. IMAP-002), the fallback loops `await this.client.messageDelete(id, { uid: true })` per UID. Each call does a full EXPUNGE round-trip (per imapflow source — `messageDelete` runs `EXPUNGE`, not `STORE +FLAGS \Deleted`). N UIDs → N EXPUNGE round-trips, all serialised under the held mailbox lock. For 1000 UIDs that's many minutes of held lock; concurrent reads/IDLE-driven cache invalidations block.
- Repro hypothesis: Trigger the fallback path (mock batch `messageDelete` to throw once); time per-UID loop for 100 UIDs against Bridge.
- Suggested next step: Replace per-UID loop with `messageFlagsAdd(uidSet, ['\\Deleted'])` then a single trailing `expunge()` — saves N-1 round-trips. Or chunk via IMAP-002's fix and skip the per-UID degradation entirely.
> **Resolved in v3.0.52 — IMAP service hardening.** The delete fallback in `chunkedBatchOp` now flags `\Deleted` per UID (cheap `STORE`) and runs exactly one trailing EXPUNGE of the flagged set via a new optional `finalize` callback (invoked only when the per-UID fallback actually ran). Both bulk-delete call sites pass it; semantics unchanged, N−1 EXPUNGE round-trips eliminated.

#### IMAP-011 — `expandImapSequence` accepts comma/colon input with no validation; NaN propagates
- Location: `src/services/simple-imap-service.ts:98-106`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `parseInt(p, 10)` returns `NaN` for malformed input. The `for (let i = a; i <= b; i++)` loop never terminates if `a` is finite and `b` is NaN (because `NaN <= NaN` is false — it terminates), but the simpler case `'1:*'` (which Bridge does return for unbounded ranges) produces `a=1, b=NaN`, so the function returns `[1]`. If Bridge ever returns the ESEARCH PARTIAL form `1:50` *plus a `*`* the call to `expandImapSequence` would return mis-shaped data. Also, a hostile or buggy server returning a huge range like `1:1000000000` would allocate a billion-element array and OOM the process.
- Repro hypothesis: Unit test: `expandImapSequence('1:1000000000')` — the process allocates an integer array of one billion `number`s (~8 GB). `expandImapSequence('a:b')` returns `[NaN]`.
- Suggested next step: Cap the produced count (e.g. throw at >10 000 UIDs); validate `a` and `b` are non-NaN; reject `*`.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `expandImapSequence` rejects non-integer / `*` / inverted parts and checks the projected size against a 10 000-UID cap *before* expanding, so `1:*` throws (no more silent `[1]`) and `1:1000000000` throws instead of OOMing. Regression tests added.

#### IMAP-013 — `findDraftsFolder` swallows `getFolders` exceptions, falls back to literal `'Drafts'`
- Location: `src/services/simple-imap-service.ts:1352-1356`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: If folder discovery throws (network, auth), `saveDraft` proceeds to `client.append('Drafts', …)`. On Proton Bridge the special-use Drafts folder is at exactly `Drafts`, so this *usually* works — but a user with a localised Bridge (e.g. `Brouillons`) or a Bridge build that nests drafts under a custom path will get an `APPEND` against a non-existent folder. The error surfaces from `client.append`, so this is mostly a UX wart, not data loss, but the comment "swallow — fall through to default" hides the actionable failure.
- Repro hypothesis: Mock `getFolders` to reject; call `saveDraft`. Without a real `Drafts` folder the user gets the cryptic Bridge error rather than "folder discovery failed: <reason>".
- Suggested next step: Re-throw with context if the discovery error itself was not a "nothing found" case.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** The `catch` in `findDraftsFolder` now logs the discovery error at `warn` (with the cause) before returning `null`, so a network/auth failure is observable rather than silently collapsed into "no Drafts folder". (`saveDraft`'s structured `null`-folder error already shipped in v3.0.58.)

#### IMAP-015 — `validateEmailId` allows arbitrary-length decimal strings; UIDs > 2³² are silently meaningless
- Location: `src/services/simple-imap-service.ts:285-289`
- Category: Defense-in-depth
- Severity: Low
- Confidence: Confirmed
- Description: `/^\d+$/.test(id)` lets `'99999999999999999999'` through. IMAP UIDs are 32-bit unsigned (`0..4_294_967_295`). Bridge will either reject or truncate; the fallback path in every bulk method then reports `UID not found` per the v3.0.41 pre-flight, which is *correct behaviour* but obscures that the input was structurally garbage. The class of UID-shaped-but-not-a-UID input also enables minor log poisoning since the strings are echoed into log lines.
- Repro hypothesis: `markEmailRead('1'.repeat(50))` — validation passes, lock acquired on resolved folder, FETCH returns nothing, error message includes 50 ones.
- Suggested next step: Tighten to `/^[1-9]\d{0,9}$/` and reject values `> 4_294_967_295`.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `validateEmailId` now enforces exactly `/^[1-9]\d{0,9}$/` and rejects values above `4_294_967_295`. Regression tests added (50-nines, ceiling+1, leading zero).

#### IMAP-018 — `getEmails` bodyPart `'1'` extraction is a heuristic that breaks on `text/html`-only emails
- Location: `src/services/simple-imap-service.ts:743-758`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: The code fetches `bodyParts: ['1']` and assumes that part is text. For `multipart/alternative` messages where `text/plain` is part 1 this is correct; for `multipart/alternative` where the order is `text/html` then `text/plain` (some senders), or for `text/html`-only emails, the heuristic gets the HTML in part 1 (caught later by the `looksLikeHtml` regex). But for `multipart/related` where part 1 is the root `multipart/alternative`, the fetched bytes are MIME boundaries — the preview is a few "----=_Part" markers. This is purely a preview-quality issue, not data loss, but it ships in the list UI.
- Repro hypothesis: Craft a `multipart/related` test message; `getEmails()` returns a preview containing boundary markers.
- Suggested next step: Walk `bodyStructure` to find the canonical `text/plain` part and fetch that path (e.g. `'1.1'`), or fall back to `'TEXT'`.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `getEmails` detects when bodyPart `'1'` is MIME-boundary/header noise (`multipart/related` root) and suppresses it from the preview instead of shipping `----=_Part…` markers into the list view. (Pure preview-quality; no second fetch added.)

#### IMAP-019 — `disconnect()` calls `logout()` without a try/catch — error path leaves `client` non-null + `isConnected=true`
- Location: `src/services/simple-imap-service.ts:546-556`
- Category: Defense-in-depth
- Severity: Low
- Confidence: Confirmed
- Description: If `await this.client.logout()` rejects (Bridge ungracefully closed the TCP socket; common during shutdown), the method propagates the error and the lines `this.client = null; this.isConnected = false;` are never reached. The next call to `ensureConnection` sees `isConnected === true` and `client !== null` and proceeds; the next operation throws on the dead socket. Healthcheck/`isActive()` lies about the connection state.
- Repro hypothesis: Unit test: mock `logout` to reject; call `disconnect()`; expect rejection; then expect `isActive() === false`.
- Suggested next step: Wrap the `logout` call in try/finally; always null `client` and set `isConnected = false`.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `disconnect()` wraps `logout()` in try/catch/finally — a rejected logout logs at `warn` and the `finally` always nulls `client` + clears `isConnected`. Regression test asserts `isActive() === false` after a rejecting logout.

#### IMAP-020 — Search criteria string truncation is too aggressive: `\` removal can change semantic meaning
- Location: `src/services/simple-imap-service.ts:968`
- Category: Correctness
- Severity: Low
- Confidence: Suspect
- Description: `sanitizeImapStr` strips `"` and `\` to prevent quote injection. But IMAP literals (`{N}\r\n…<bytes>`) are imapflow's normal serialisation strategy for any string containing a `"`. The proper fix is to send the input as an IMAP literal instead of a quoted string — imapflow already does that automatically. Silently dropping `\` changes searches like `from:O\'Brien` → `from:OBrien`, which is wrong, and dropping `"` from a body search for `He said "hello"` returns a different result set than the user asked for. This is a *correctness regression caused by defensive sanitisation*.
- Repro hypothesis: Search with `subject: 'urgent "now"'`; the result set is for `subject: 'urgent now'`. Compare to a Bridge wire trace.
- Suggested next step: Let imapflow handle quoting/literal encoding; do not pre-strip. Or — if the concern is a specific imapflow version that mis-quotes — pin the imapflow version and add a regression test against a captured wire transcript.
> **Acknowledged in v3.0.61 — by design:** the `"`/`\` strip in `sanitizeImapStr` is a deliberate, audited IMAP SEARCH injection defense (paired with the VALID-002 CR/LF/NUL strip). Trading it for rare search-fidelity edge cases (`O\'Brien`, quoted phrases) would reopen the injection surface this sweep is hardening; deferred unless a captured wire transcript shows imapflow quoting safely on the pinned `~1.3.1`.

#### IMAP-022 — `getFolders` issues one `STATUS` per folder serially; bottleneck on accounts with many labels
- Location: `src/services/simple-imap-service.ts:658-681`
- Category: Performance
- Severity: Low
- Confidence: Confirmed
- Description: The `for (const folder of folders)` loop awaits `client.status(...)` serially for every folder. On a Proton account with 30 labels + custom folders, that's 30 sequential round-trips per cache miss — well over a second over Bridge. The 5-minute cache mitigates frequency, but every fresh start, every cache-clearing event, and every `clearFolderCache()` after a folder mutation re-pays the cost while a single mailbox lock is *not* held — so other operations can race.
- Repro hypothesis: Time `getFolders()` after `clearCache()` against a 30-folder Bridge account.
- Suggested next step: Use `Promise.all`/concurrent status fetches (imapflow tolerates parallel STATUS commands), or switch to the LIST-STATUS extension (`returnOptions: [{ status: ['messages','unseen'] }]`) — one round-trip.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `getFolders` issues the per-folder `STATUS` probes via `Promise.all` (imapflow pipelines them) instead of awaiting each serially, collapsing 30+ sequential round-trips on a label-heavy account to roughly one.

#### IMAP-017 — IDLE handlers reference cache by raw `id` variable but the value is a `folder:uid` key — variable shadows the UID concept
- Location: `src/services/simple-imap-service.ts:2575, 2582`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: The destructuring `for (const [id, entry] of this.emailCache)` gives `id = 'INBOX:1234'` — a cache key, not a UID. Every other method in the file uses `emailId` for raw UIDs and `cacheKey` for the qualified key. The mis-naming makes the code read as if the eviction is keyed on a UID, which it is not — and obscures the case-sensitivity issue called out in IMAP-005.
- Repro hypothesis: N/A (style).
- Suggested next step: Rename `id` → `cacheKey` in both handlers.

> **Acknowledged in v3.0.64 — by design: already addressed.** The IMAP-005 fix replaced the IDLE eviction loops; `evictInboxCacheEntries` and the remaining cache iterators already use `cacheKey`/`key`, so the misleading `id` binding the finding flagged no longer exists.

#### IMAP-021 — `bridgeVersion` field is public-writable; not marked `private` or `readonly`
- Location: `src/services/simple-imap-service.ts:503`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: `bridgeVersion: string | null = null;` — public mutable. Anything in the process can scribble over it, and a stale value from a previous Bridge connection survives across `disconnect()`/`connect()` (no reset). Combined with `insecureTls` on line 133 — also public, also not reset on `disconnect()` — the class's contract surface is leaky.
- Repro hypothesis: Connect, disconnect, connect again to a different host. `bridgeVersion` and `insecureTls` retain their old values until the new connect path overwrites them — and the version probe is fire-and-forget, so there is a window where `bridgeVersion` reflects the previous bridge.
- Suggested next step: Make `private`, expose a getter, reset both at the top of `connect()` and in `disconnect()`.

> **Acknowledged in v3.0.64 — by design: leaky-field hardening is a non-trivial contract change.** Making `bridgeVersion`/`insecureTls` private with getters and resetting them across connect/disconnect is a class-surface refactor with reconnect-path risk; not a trivial Info fix. No security impact today (single-process, single-owner).

**IMAP-CORRECTNESS patterns:** the v3.0.41 fix correctly added the `sourceFolder` + pre-flight `UID FETCH` pattern to the singular and most-bulk mutators, but the rebuild was *not* mechanically uniform: `setFlag` was missed entirely (IMAP-008, IMAP-009), and three of the bulk methods kept a soft `?? 'INBOX'` fallback that recreates the same silent-no-op class the rest of the fix closed (IMAP-003). Defensive `catch {}` blocks are the second pattern — they hide UIDVALIDITY mismatches, connection-reset errors during preflight, draft-folder discovery failures, connection-lost reads, and `disconnect()` errors; each one converts an actionable failure into a misleading success path. The third pattern is wire safety being uneven: SMTP has a thorough `stripHeaderInjection` helper but `options.header` (IMAP-004) is passed through raw; `sanitizeImapStr` is itself heavy-handed enough to be a correctness bug (IMAP-020). The fourth is scale assumptions baked into IMAP command construction (IMAP-002, IMAP-016) and `expandImapSequence` (IMAP-011). Finally, the IDLE client lives in a parallel universe from the main client (IMAP-001, IMAP-005).

### SMTP / drafts / scheduling / reminders

#### SMTP-001 — `persist()` race: scheduler write during in-flight `sendMail()` can lose newly-arrived items
- Location: `src/services/scheduler.ts:170-208`
- Category: Concurrency / Data-loss
- Severity: High
- Confidence: Likely
- Description: `processDue()` filters `due` by reference into a local array, then awaits `smtpService.sendEmail()` for each. While a single `sendMail()` is in flight, the public methods `schedule()` and `cancel()` mutate `this.items` and call `persist()` synchronously — but the `due` array still holds references to the old objects and `processDue` ends with one final `this.persist()` (line 203) that serializes the (now possibly stale) full array. A `schedule()` that lands between is preserved (it's appended to `this.items` and rewritten by the next `persist`), but a `cancel()` that lands while we're between the `sendEmail()` resolve and the status assignment (lines 176–177) will be overwritten by `item.status = "sent"` on line 178 — i.e., a successful send happens, the user already saw `cancel()` return `true`, but the file persists `status:"sent"`.
- Repro hypothesis: schedule item; advance to due; in a debugger pause after `await this.smtpService.sendEmail(item.options)` returns success and before line 178; call `svc.cancel(id)` — returns true and persists "cancelled"; resume — line 178 silently flips it back to "sent" and line 203 persists "sent". User sees inconsistent UI.
- Suggested next step: re-check `item.status === "pending"` after the await and skip the status flip if the item was cancelled mid-send; or use a per-item lock map.
> **Resolved in v3.0.47 — Batch 6.** `processDue()` re-reads `item.status` after `await sendEmail(...)` and skips the `"sent"`/`"failed"` flip when a concurrent `cancel()` has already written `"cancelled"`. New `"sending"` status flipped + persisted before the await makes the cooperative-stop signal observable.

#### SMTP-002 — Send-after-cancel TOCTOU: `cancel()` returns true after `sendEmail` started
- Location: `src/services/scheduler.ts:121-129` & `src/services/scheduler.ts:160-176`
- Category: Concurrency
- Severity: High
- Confidence: Confirmed
- Description: `processDue()` snapshots `due` at line 161, then `await`s `sendEmail` (line 176). `cancel()` (line 121) only checks `item.status !== "pending"`; the status is not flipped to anything in-flight before the await. Between line 161's snapshot and line 178's `item.status = "sent"`, a caller can `cancel()` the same id — the cancel is reported successful (return true, persisted "cancelled") but the SMTP send has already gone out the door. The "cancelled" record in the on-disk store will then be clobbered by line 203's `persist()` rewriting it as "sent" (see SMTP-001).
- Repro hypothesis: schedule item due at T; at T `processDue` reads, calls `sendEmail`; before SMTP returns user calls `cancel_scheduled_email` — gets `success:true` — but Proton already accepted. Apparent broken-promise to user.
- Suggested next step: introduce an `inFlight` set/status `"sending"` flipped before the await; `cancel()` should refuse for `"sending"` items and return a distinct error.
> **Resolved in v3.0.47 — Batch 6.** `cancel()` now returns a discriminated `CancelResult` and emits `{ ok: false, error: "in_flight" }` when the target is `"sending"`. The `cancel_scheduled_email` tool handler maps this to a user-facing warning that the message may still be delivered, instead of the prior misleading success.

#### SMTP-003 — `processDue` retry loop has no backoff between attempts on a single item
- Location: `src/services/scheduler.ts:174-201`
- Category: Performance / UX
- Severity: Medium
- Confidence: Confirmed
- Description: When `sendEmail` returns `{success:false}` for a transient reason that is not classified by `isTransientAbuseError` (e.g. DNS hiccup, connection reset), `processDue` increments `retryCount` and leaves status `"pending"`. The next `setInterval` tick (60 s later) re-tries. There is no per-item next-attempt delay — if 50 items are pending and Bridge is briefly down, every cycle re-burns all 50 through `sendEmail` in a tight for-loop. Combined with SMTP-service-level `BackoffTracker` only firing on abuse-coded errors, a generic transient blast will hammer Bridge once per minute until the 3-attempt cap is hit.
- Repro hypothesis: stop Bridge; schedule 20 due items; observe 20 connect attempts per minute against the down Bridge.
- Suggested next step: store `nextAttemptAt` on each item, set to `now + exp_backoff(retryCount)`; skip items whose `nextAttemptAt > now`.
> **Resolved in v3.0.55 — persistence atomicity.** Added `nextAttemptAt` (exp backoff 60s→…→30m cap) on each failed send; `processDue()` skips items whose `nextAttemptAt > now`, and clears it on success.

#### SMTP-004 — `retryCount` bumped before send actually fails to commit, but `persist()` only fires after the whole loop
- Location: `src/services/scheduler.ts:174-207`
- Category: Data-loss
- Severity: Medium
- Confidence: Likely
- Description: `processDue` mutates `item.status`, `item.retryCount`, `item.error` in-memory inside the loop but only calls `this.persist()` once after the `for` loop completes (line 203). If the process crashes (uncaught throw in an unrelated handler, or SIGKILL during the 30+ second SMTP timeout window) mid-loop, items already sent in this batch have `status="sent"` in memory but the file still says `"pending"`. On restart `start()` → `load()` → `processDue()` will re-send those messages. SMTP is not idempotent — recipients get duplicates.
- Repro hypothesis: 5 due items; kill -9 the process after item 3 finishes. Restart: items 1–3 re-sent (re-Message-IDed), Proton accepts.
- Suggested next step: `persist()` after each item's status flip, not only at end of batch.
> **Resolved in v3.0.55 — persistence atomicity.** `persist()` now runs after every per-item terminal/retry status flip inside the loop, so a crash mid-batch cannot leave a sent item on disk as `pending` and re-send it on restart.

#### SMTP-005 — Reminder `persist()` uses non-atomic cross-device rename
- Location: `src/services/reminder-service.ts:70-75`
- Category: Data-loss
- Severity: Medium
- Confidence: Likely
- Description: The tmp file is written under `tmpdir()` (typically `/tmp`, often `tmpfs`) then `renameSync` into `this.path` (typically `$HOME/.mailpouch-reminders.json`). On Linux `rename(2)` across mounts (tmpfs → ext4) fails with `EXDEV`, throwing synchronously from `add()` / `cancel()` / `scanDue()`. The scheduler peer (`src/services/scheduler.ts:271-282`) deliberately writes the tmp in the same directory as the store for this reason. Result: on systems where `$HOME` is on a different mount than `/tmp` (containerised installs, autofs-mounted homedirs), every reminder write throws — `add()` is called from `remind_if_no_reply` and the McpError surfaced says "Failed to persist reminder: EXDEV: cross-device link not permitted".
- Repro hypothesis: mount `$HOME` on a different fs than `/tmp` (Docker volume, NFS home); call `remind_if_no_reply` — throws EXDEV. All reminder writes are unrecoverable.
- Suggested next step: build tmp path as `this.path + ".tmp"` (or use `dirname(this.path)`), matching `scheduler.ts`.
> **Resolved in v3.0.55 — persistence atomicity.** `persist()` now writes the tmp file as `${this.path}.<rand>.tmp` (sibling of the store), so `rename(2)` stays within one filesystem and no longer throws `EXDEV` on split `$HOME`/`/tmp` mounts.

#### SMTP-006 — Reminder `persist()` exception is unhandled — corrupts in-memory state
- Location: `src/services/reminder-service.ts:81-108`, `122-128`, `188-201`
- Category: Data-loss
- Severity: Medium
- Confidence: Confirmed
- Description: `add()`, `cancel()`, `scanDue()`, `prune()`, and `detectRepliesAndCancel()` mutate `this.reminders` before calling `persist()`. If `persist()` throws (out-of-space, EXDEV per SMTP-005, EACCES on the file), the in-memory state is already advanced (status flipped to "fired", new reminder appended) but the disk doesn't reflect it. On the next `persist()` call the disk catches up — so a *successful* later call can flush the half-baked mutations from the failed one (e.g., a "fired" reminder that the caller never received gets persisted later as "fired", losing the at-least-once delivery guarantee `check_reminders` implies).
- Repro hypothesis: make `path` read-only; call `scanDue()` — pending reminders flip to "fired" in memory and are returned, but writeback throws. Then `chmod +w`; call `cancel()` on an unrelated reminder; the failed "fired" mutations now land on disk silently.
- Suggested next step: try/catch around `persist()`; on failure, roll back the in-memory mutation, log loudly.
> **Resolved in v3.0.55 — persistence atomicity.** `add`/`cancel`/`scanDue`/`prune`/`detectRepliesAndCancel` snapshot `reminders` before mutating and persist via `persistOrRollback()`, which on write failure restores the snapshot, logs at `error` level, and re-throws — so a failed write can never be silently flushed later.

#### SMTP-007 — `scanDue()` is destructive and not idempotent under partial-write failure
- Location: `src/services/reminder-service.ts:188-201`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: `scanDue()` is invoked by the `check_reminders` MCP tool (`src/tools/drafts.ts:524-537`). It transitions every due reminder to `"fired"` *and* returns it in the same call. If the network/MCP transport drops the response before the agent receives it, the disk has already been written: the agent will never see those reminders again, and the user gets no notification. There is no acknowledgment step.
- Repro hypothesis: kill the MCP transport between persist (line 199) and the tool response writeback in `check_reminders`. Reminders are lost.
- Suggested next step: defer the status transition to a separate `acknowledge_reminder(id)` tool, or add an `ack` step the agent must call.
> **Resolved in v3.0.55 — persistence atomicity (partially-addressed).** The crash-safety half is closed: the `pending → fired` transition is now persisted (and rolled back atomically with the in-memory state on a write failure — see SMTP-006) *before* `scanDue()` returns the due list, so a write that didn't reach disk never hands the caller reminders it can't re-fire. The at-least-once limitation (a dropped MCP transport response *after* a successful persist) remains **by design** pending a dedicated `acknowledge_reminder` tool; adding that tool is a product change deliberately out of scope for this batch.

#### SMTP-011 — `saveDraft` falls back to literal "Drafts" path with no existence check
- Location: `src/services/simple-imap-service.ts:1341-1357` & `1448-1449`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: When `findDraftsFolder` finds no `\Drafts` special-use folder and no name match, it returns the literal string `"Drafts"`. `saveDraft` then calls `this.client.append("Drafts", rawMime, ['\\Draft'])`. If the account has no "Drafts" mailbox (Proton's name is `Drafts` so usually OK, but custom-named accounts, non-English locales, or accounts where the user renamed/deleted the folder), the IMAP server will return `NO [TRYCREATE]` or similar. `saveDraft` catches it (line 1454) and returns `{success:false, error: <imap message>}` — so the user sees a generic IMAP error rather than an actionable "no Drafts folder configured." Worse, if Drafts is read-only/no-perm (subscribed but `\Noselect`), append will fail late.
- Repro hypothesis: rename Drafts → "Brouillons" in Proton UI; call `save_draft` — returns IMAP error like `Mailbox doesn't exist: Drafts`.
- Suggested next step: when fallback is taken, log a warning + return a structured `error: "Drafts folder not found; configure or create one"`; optionally try `client.mailboxCreate("Drafts")` before append.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** `findDraftsFolder` now returns `null` (instead of the literal `"Drafts"`) when no `\Drafts` special-use folder and no name match exist; `saveDraft` logs a warning and returns `{success:false, error:"No Drafts folder found for this account; create or configure a Drafts mailbox."}` without attempting the append.

#### SMTP-012 — `saveDraft` does not strip CRLF from `subject` like SMTP path does
- Location: `src/services/simple-imap-service.ts:1404-1416`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: SMTP `sendEmail` runs `stripHeaderInjection(options.subject)` (`smtp-service.ts:321`); the IMAP draft path passes `options.subject || '(No Subject)'` straight into nodemailer's `mailOptions.subject` with no sanitization. Nodemailer is generally CRLF-aware on the well-known `subject` header so this may be mitigated downstream, but the asymmetry contradicts the comment block on lines 1411-1413 that explicitly claims "Mirrors the stripHeaderInjection() call in smtp-service.ts" (it only mirrors for `inReplyTo` / `references` / `attachments`, NOT subject, To, Cc, Bcc). A draft can be saved with a header-injected subject; once the user opens and sends it from Proton web, the behavior depends on Proton's own re-encoding.
- Repro hypothesis: call `save_draft` with `subject: "Hello\r\nBcc: leak@evil.com"`; inspect the appended MIME for an injected `Bcc:` line.
- Suggested next step: pre-strip subject / to / cc / bcc through the same regex `[\r\n\x00]` before passing to nodemailer; update the comment.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** `saveDraft` now runs `subject`, `to`, `cc`, and `bcc` through a `stripCrlf()` (`[\r\n\x00]`) helper before handing them to nodemailer, matching `stripHeaderInjection()` on the SMTP path; the misleading comment was corrected.

#### SMTP-008 — `processDue` "isProcessing" guard skips items silently across overlapping ticks
- Location: `src/services/scheduler.ts:156-168`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: When a slow SMTP send (say, 90s) is in flight, the next 60-s `setInterval` tick calls `processDue()`, hits `if (this.isProcessing) return;` (line 157), and returns. New items that became due in the meantime are not processed until the *next* tick, but `due` is recomputed on each call, so eventually they'll be picked up. Not a leak — but the early-return at line 157 doesn't even unset `isProcessing` (it's already true) and doesn't log; debugging "why didn't my email send at 12:34?" is hard.
- Repro hypothesis: a single hanging recipient (e.g. greylisted) blocks the loop; subsequent ticks no-op for the duration of the hang.
- Suggested next step: log a `debug` line when skipping; consider sending each due item with a fire-and-forget `Promise.allSettled` so one slow recipient doesn't block the queue.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `processDue` now logs a `debug` line ("processDue skipped — previous tick still in flight") on the `isProcessing` early-return, making the skip diagnosable. (The `allSettled` fan-out is a larger concurrency change left out of this low-severity batch.)

#### SMTP-009 — `reply_to_email` sends with subject that bypasses `MAX_SUBJECT_LENGTH`
- Location: `src/tools/sending.ts:189-192`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: The forward path (line 240-242) caps `fwdSubject` to `MAX_SUBJECT_LENGTH`. The reply path computes `subject = "Re: " + cleanSubject` and passes it straight to `smtpService.sendEmail` without a length check. A pathological original subject (998 chars is RFC's hard cap) plus "Re: " prefix crosses the limit; nodemailer wraps the line, but the send_email entry point has a guard the reply path lacks. Asymmetric defense.
- Repro hypothesis: reply to a message whose subject is exactly 998 chars; `Re: ` + subject is 1002 chars — exceeds the `MAX_SUBJECT_LENGTH` (RFC 2822 limit `send_email` enforces).
- Suggested next step: apply the same `slice(0, MAX_SUBJECT_LENGTH)` truncation as the forward path.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `reply_to_email` now slices the computed `Re:` subject to `MAX_SUBJECT_LENGTH`, matching the forward path. Regression test added.

#### SMTP-010 — `forward_email` does NOT sanitize `args.message` before splicing into body
- Location: `src/tools/sending.ts:253-257`
- Category: Security
- Severity: Low
- Confidence: Suspect
- Description: `userMessage` is concatenated directly with the forwarded body. `isHtml` is inherited from `fwdOriginal.isHtml`, so if the original was HTML, `args.message` is treated as HTML and is not escaped — an agent that passes user-controlled text as `message` could inject HTML/JS into the outgoing message. Body-injection (not header-injection), so impact is limited to whatever the recipient's mail client renders.
- Repro hypothesis: forward an HTML message with `message: "<script>alert(1)</script>"`. The script is delivered intact in the HTML body.
- Suggested next step: when `fwdOriginal.isHtml`, run `args.message` through `escapeHtml()` (already defined in `smtp-service.ts:25-31`).
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `escapeHtml` is now exported from `smtp-service.ts`; `forward_email` runs `args.message` through it when `fwdOriginal.isHtml`, so user-supplied text can't inject markup/script into the HTML body. Regression tests cover both the HTML (escaped) and plain-text (untouched) cases.

#### SMTP-013 — `send_email` allows missing subject; `subject` is `required` in the input schema but the handler does not enforce it
- Location: `src/tools/sending.ts:131-136`, schema at `src/tools/sending.ts:51`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: The `inputSchema` lists `subject` in `required` (line 51), but the handler only validates `if (args.subject !== undefined && typeof args.subject !== "string")`. If MCP transport doesn't enforce the schema (and many don't, the SDK validates inputSchema only for documentation purposes), an agent passing `{to, body}` reaches `smtpService.sendEmail({subject: undefined as any, ...})` — nodemailer accepts undefined subject and sends "(no subject)" or empty.
- Repro hypothesis: call `send_email` with `subject` omitted; SMTP send goes out with an empty Subject header.
- Suggested next step: add an explicit required-string check matching `to` / `body`.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `send_email` now rejects an omitted or blank `subject` with an `InvalidParams` McpError ("'subject' must be a non-empty string."), matching the `to`/`body` guards. Regression tests cover the omitted and whitespace-only cases.

#### SMTP-014 — `parseEmails` silently drops invalid addresses; recipient count can fall below caller's intent
- Location: `src/services/smtp-service.ts:278-312` & `src/utils/helpers.ts:43-63`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `parseEmails` silently drops invalid addresses with a warn-log; the SMTP layer then validates the survivors. A caller submitting `"alice@x.com, bogus, bob@y.com"` proceeds with `[alice, bob]` and no signal that bogus was dropped. The "At least one recipient is required" check at line 293 only fires if *all* addresses are bad. For scheduled sends this happens silently 60+ seconds after the user requested it.
- Repro hypothesis: schedule with `to: "good@x.com, ,@bad"`; the schedule succeeds; 60s later the send goes to only `good@x.com`; user never learns the second recipient was dropped.
- Suggested next step: have `parseEmails` either throw on partial failure or return `{ valid, dropped }`; surface the dropped list to the caller.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** New `parseEmailsDetailed` returns `{ valid, dropped }` (and `parseEmails` now delegates to it, unchanged). `SMTPService.sendEmail` uses the detailed form for the primary `to` field and hard-fails with `Invalid recipient address(es) in "to": …` on any partial drop, so a typo'd recipient can't silently shrink the set. Regression tests added.

#### SMTP-015 — `BackoffTracker` is per-SMTPService, but `processDue` iterates items synchronously: a backoff trip blocks the rest of the batch
- Location: `src/services/scheduler.ts:174-201` & `src/services/smtp-service.ts:265-275`
- Category: Performance
- Severity: Low
- Confidence: Likely
- Description: Inside `processDue`, when item N trips the backoff (lines 453-459 of smtp-service set `record("abuse")`), items N+1..M in the same batch hit the gate at line 265 and return `success:false` with `error: "SMTP backoff active — ..."`. Their `retryCount` is bumped (line 181). After 3 such batches every queued item is permanently `"failed"` — even though the underlying issue is throttling and the items were never actually attempted. Combined with SMTP-004's lack of per-item persistence, this can mass-fail the queue.
- Repro hypothesis: schedule 5 items due at the same minute; first send returns SMTP 421; subsequent four immediately return "backoff active" and bump retryCount; do this for two more ticks; all 5 are "failed".
- Suggested next step: when backoff is active, leave `retryCount` untouched (treat as "not attempted"); abort the rest of the loop early.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `processDue` checks `smtpService.backoff?.isBlocked()` at the top of each iteration and `break`s the batch when the gate is tripped, leaving the remaining items cleanly `"pending"` (no `retryCount` burn). Regression test asserts no send is attempted and `retryCount` stays 0.

#### SMTP-016 — `remind_if_no_reply` fetches by UID without confirming the message lives in the requested folder
- Location: `src/tools/drafts.ts:450-498`
- Category: Correctness
- Severity: Low
- Confidence: Suspect
- Description: `getEmailById(remEmailId, folderHint)` is called with `folderHint` defaulting to `"Sent"`. IMAP UIDs are folder-scoped; if the caller passes a UID that was a *Sent* UID but the user has switched folders since the agent fetched it, the reminder may attach to the wrong message (different Message-ID, same UID number). Symptom: reminder never auto-cancels when the matching reply arrives because the persisted `messageId` is from an unrelated message.
- Repro hypothesis: get a UID from "Sent", store; user moves messages such that the UID maps to a different message; call `remind_if_no_reply` with the stale UID — silently persists a reminder for the wrong subject/recipient.
- Suggested next step: include the Message-ID directly in the input schema as an option, or have the tool re-fetch + sanity-check the From/Date.
> **Acknowledged in v3.0.61 — by design:** `remind_if_no_reply` already re-fetches the message and binds the reminder to *that message's* real `Message-ID` header (rejecting messages with no Message-ID), and reply auto-cancel matches on `messageId`, not UID — so a stale UID resolves the reminder to whatever message it currently points at, never silently to a wrong subject. Adding a Message-ID input option is a product/schema change deliberately out of scope for this batch.

#### SMTP-018 — `MAX_HISTORY_RECORDS` / `MAX_HISTORY_AGE_MS` only enforced on `load()`, not on persist
- Location: `src/services/scheduler.ts:210-228` & `270-282`
- Category: Performance
- Severity: Low
- Confidence: Confirmed
- Description: `pruneHistory` is only called from `load()` (line 221). A long-running process can accumulate >1000 non-pending records in memory and rewrite the full array on every `persist()` (called per schedule / cancel / processDue tick). After 30 days of heavy use, every cancel writes a megabyte+ JSON blob. The cap is enforced only at next start.
- Repro hypothesis: keep process running 6 months with high schedule volume; observe `.mailpouch-scheduled.json` grow unboundedly; restart shrinks it.
- Suggested next step: invoke `pruneHistory` from `persist()` too, or periodically from the poll loop.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `persist()` now runs `this.items = this.pruneHistory(this.items)` before writing, so the cap is enforced on every schedule/cancel/tick write rather than only at next `load()`. Regression test seeds >1000 cancelled records and asserts the on-disk non-pending count is capped without a restart.

#### SMTP-017 — `_resetBridgeCertPinsForTests` is exported from production code
- Location: `src/services/bridge-tls.ts:50-52`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: Test-only mutation surface in production bundle. Not a security bug today but it lets any consumer (or compromised dep that gets a reference) reset the pinned-hash table that exists to prevent cert-swap TOCTOU. Underscore prefix is convention-only.
- Repro hypothesis: import `_resetBridgeCertPinsForTests` from prod build; call it; subsequent cert change is no longer flagged.
- Suggested next step: gate behind `if (process.env.NODE_ENV === 'test')` or move to a separate `__test__` entry point.

> **Acknowledged in v3.0.64 — by design: test seam stays.** `_resetBridgeCertPinsForTests` is underscore-flagged and only resets an in-process pin table; gating it behind `NODE_ENV` or a separate entry point touches the TLS-pinning security surface and is out of scope for a trivial Info fix.

#### SMTP-019 — `tracer.spanSync` callbacks not indented, breaking visual block structure
- Location: `src/services/scheduler.ts:58-67`, `71-78`, `89-117`, `122-129`, `135-141`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: The body of each `tracer.spanSync('...', {}, () => { ... })` callback is at the same indent level as the surrounding method body, with a trailing `}); // end tracer.spanSync(...)` comment. This visually flattens the closure scope and trips linters / reviewers.
- Suggested next step: indent the callback body, or extract the spanned block into a private helper.

> **Acknowledged in v3.0.64 — by design: cosmetic.** The flat `tracer.spanSync(...)` callback indentation is a deliberate, repo-wide style (paired with the `// end tracer.spanSync(...)` markers) to avoid reindenting large method bodies; no behavior or lint failure.

#### SMTP-020 — `escapeHtml` does not escape apostrophe; safe to use in attribute-free contexts only
- Location: `src/services/smtp-service.ts:25-31` & `485-494`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: `escapeHtml` does not escape `'`. Safe in attribute-free contexts (current usage), but a future caller embedding an escaped value inside `'...'` attribute quotes (`<a href='${escapeHtml(x)}'>`) would let apostrophes break out.
- Suggested next step: also replace `'` with `&#39;` to make the helper safer to reuse.

> **Resolved in v3.0.64 — Info triage.** `escapeHtml` now also escapes `'` → `&#39;`, making the helper safe to reuse inside single-quoted attribute contexts.

**SMTP-DRAFTS-SCHED patterns:** the persistence story across both `SchedulerService` and `ReminderService` is single-process, single-writer JSON-blob-on-mutation — no journaling, no per-record commits, no atomic in-place write for reminders, and pruning is start-up-only for the scheduler. That class of design is fine when mutations are rare, but `processDue` calls `persist()` once per batch (SMTP-004) and the reminder service only writes after mutating in-memory state with no rollback on write failure (SMTP-006). The whole subsystem behaves correctly under happy paths and falls over under partial failure: cross-device tmp dirs (SMTP-005), crashes mid-batch (SMTP-004), or transport drops after a destructive `scanDue` (SMTP-007). Concurrency control is also informal — `processDue` snapshots `due` by reference and lets `cancel()` race against the in-flight `sendEmail` (SMTP-002). Header-injection defenses are comprehensive on the SMTP path but missing in the IMAP draft path for subject/to/cc/bcc (SMTP-012), despite a comment claiming parity. Validation is also asymmetric across tools (SMTP-009, SMTP-013).

### Transports, OAuth, remote mode

#### XPORT-002 — Phishable `client_name` in DCR endpoint enables consent-screen spoofing
- Location: `src/transports/oauth-handlers.ts:192-228, 425-432`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `handleRegister` accepts an arbitrary `client_name` string from a public, unauthenticated endpoint and stores it verbatim. The consent page renders `${esc(ctx.clientName)} (${esc(ctx.clientId)})` so the operator sees attacker-supplied text. An attacker who can reach the OAuth endpoints (the whole point of remote mode) registers a client called `Claude Desktop` or `mailpouch internal` and a `redirect_uri` of `https://attacker.example/cb`; the admin sees a familiar name and types the admin password.
- Repro hypothesis: `curl -X POST http://127.0.0.1:8788/oauth/register -H 'Content-Type: application/json' -d '{"client_name":"Claude Desktop","redirect_uris":["https://attacker.example/cb"]}'` — then drive the consent flow; the page reads "Client: Claude Desktop (pmc_...)".
- Suggested next step: Mark every DCR registration with an unmistakable "Untrusted client" badge in the consent UI, length-cap `client_name`, and strip control characters; consider requiring a per-registration admin-confirmation step before tokens can be issued.
> **Resolved in v3.0.50 — Batch 5.** New `sanitizeDcrClientName()` strips ANSI + control chars + length-caps at 100 chars at registration. Consent page now renders an "⚠ Untrusted client" badge for every `token_endpoint_auth_method === "none"` client. Page-level `esc()` tightened to 5-replacement (added `>` and `'`).

#### XPORT-001 — Static bearer token forces all callers into one shared rate-limit bucket
- Location: `src/transports/http.ts:251-253, 297-301`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: All requests that authenticate with the configured `remoteBearerToken` are keyed as the literal string `"bearer:static"` for `authLimiter.take(tokenKey)`. The module header claims "the authed /mcp endpoint is rate-limited per token key so a compromised token can't DoS Bridge," but every legitimate user of the static bearer (CLI, phone, the dev's laptop, an MCP host) shares the same bucket. A single high-volume caller can lock out the others, and an attacker who steals the bearer can deliberately DoS the owner.
- Repro hypothesis: With `remoteBearerToken=secret`, run `for i in $(seq 1 200); do curl -s -H 'Authorization: Bearer secret' -d '{}' http://127.0.0.1:8788/mcp & done` from one host — concurrent legitimate calls from another host begin to receive 429s within ~2 s.
- Suggested next step: Key the auth limiter on `bearer:static:<clientIp>` so static-bearer callers get per-IP buckets while OAuth callers stay per-client.
> **Resolved in v3.0.56 — OAuth/transport hardening.** The static-bearer auth-limiter key is now `bearer:static:${caller}` (the XFF-aware `clientIp`), giving each caller its own bucket; a separate `isStaticBearer` flag carries the caller-context distinction.

#### XPORT-003 — OAuth consent page lacks clickjacking protection
- Location: `src/transports/oauth-handlers.ts:230-270, 393-450`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: `handleAuthorizeGet` writes the consent HTML with `Content-Type: text/html` and the CSP `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'`. CSP has no `frame-ancestors` directive, and no `X-Frame-Options` header is set. An attacker page can frame the consent screen and overlay UI tricking the admin into clicking "Approve" with their cached password autofilled. `src/settings/server.ts:534-536` sets `X-Frame-Options: DENY` on the settings UI — that protection wasn't carried over to OAuth.
- Repro hypothesis: Host an `<iframe src="http://127.0.0.1:8788/oauth/authorize?...">` on a malicious LAN page with z-index overlays — the consent button is clickable through the overlay.
- Suggested next step: Add `frame-ancestors 'none'` to the CSP and set `X-Frame-Options: DENY` + `X-Content-Type-Options: nosniff` on the consent response.
> **Resolved in v3.0.56 — OAuth/transport hardening.** Consent CSP now carries `frame-ancestors 'none'`; the GET response sets `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`, mirroring the settings server.

#### XPORT-006 — OAuth dispatch uses socket-only IP, mismatching the loopback-XFF model
- Location: `src/transports/oauth-handlers.ts:101-106, 137-144`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: `clientIp()` inside `oauth-handlers.ts` deliberately ignores `X-Forwarded-For`, but the rest of the transport (`src/transports/http.ts:134-151`) trusts XFF when the direct peer is loopback specifically so reverse-proxied deployments work. When `mailpouch` runs behind a localhost reverse proxy (Caddy/Cloudflare Tunnel as the docs encourage), every OAuth request appears to come from `127.0.0.1` and shares a single rate-limit bucket — the admin-password brute-force attempts from any attacker land in the same bucket as legitimate consent traffic. Conversely, the per-token IP pinning at `src/transports/http.ts:270-275` uses the XFF-aware caller, so a token issued during DCR will appear pinned to the *real* client IP and reject the *socket* IP later, breaking re-use under proxy.
- Repro hypothesis: Run behind `caddy reverse_proxy`. Burst 50 OAuth `/oauth/register` calls from one external IP — confirm that the bucket key is `oauth:127.0.0.1` and legitimate users from other IPs are immediately throttled.
- Suggested next step: Replace the local `clientIp` in `oauth-handlers.ts` with the exported one from `http.ts` so both paths agree on the trust model.
> **Resolved in v3.0.56 — OAuth/transport hardening.** The socket-only `clientIp` in `oauth-handlers.ts` was deleted; the module now imports and uses the exported `clientIp` from `http.ts`, so OAuth rate-limit keys and token IP-pinning use the same loopback-XFF trust model as the rest of the transport.

#### XPORT-007 — POST `/oauth/authorize` trusts form-supplied `code_challenge`, `state`, `resource`
- Location: `src/transports/oauth-handlers.ts:274-302`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: `handleAuthorizeGet` carefully validates `code_challenge` format (43-128 base64url, S256 required) before rendering the form. `handleAuthorizePost` then reads `body.code_challenge`, `body.state`, `body.redirect_uri`, `body.resource` straight from the form body and writes them into the issued auth code with no re-validation — anyone with the admin password can POST directly to `/oauth/authorize` with an empty / malformed / plain (non-S256) challenge.
- Repro hypothesis: `curl -X POST http://127.0.0.1:8788/oauth/authorize -d 'admin_password=...&client_id=valid&redirect_uri=valid&code_challenge=&state=&scope=mcp:full'` — the server still mints a code with an empty `codeChallenge`.
- Suggested next step: Re-run the same format checks in `handleAuthorizePost` that the GET handler enforces, and bind the auth code to the values that were displayed on the consent page (e.g., HMAC the GET parameters into a hidden field).
> **Resolved in v3.0.56 — OAuth/transport hardening.** `handleAuthorizePost` now re-runs the GET handler's `code_challenge_method` (S256), `code_challenge` format (shared `CODE_CHALLENGE_RE`), and `state` length checks before minting a code, so a direct POST can no longer mint a code with an empty/plain/malformed challenge.

#### XPORT-008 — Consent POST has no Origin/Referer or CSRF check
- Location: `src/transports/oauth-handlers.ts:274-311`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: The consent form is unauthenticated until the password is typed, but the POST handler accepts any submission with a valid password — no `Origin`/`Referer` check, no per-form CSRF token. Combined with XPORT-002, a malicious in-browser context with knowledge of the admin password (e.g., a compromised browser extension or a phished form auto-filled by a password manager) can submit `/oauth/authorize` with an attacker-controlled `client_id` and `redirect_uri` without going through the GET page first.
- Repro hypothesis: JavaScript on `http://attacker.local/` does `fetch('http://127.0.0.1:8788/oauth/authorize', {method:'POST', mode:'no-cors', credentials:'include', body:'admin_password=X&client_id=Y&redirect_uri=Z'})` — same-origin policy blocks reading the response but the auth code is issued and redirected to attacker.
- Suggested next step: Require the GET to set an `HttpOnly`+`SameSite=Strict` cookie tying the consent decision to that browser, validate `Sec-Fetch-Site: same-origin`, and reject POSTs whose `Origin` header does not match the issuer.
> **Resolved in v3.0.56 — OAuth/transport hardening.** The GET consent page now embeds a per-flow CSRF token (`HMAC(client_id)` keyed by a per-process random secret), and `handleAuthorizePost` constant-time-verifies it plus rejects any cross-site `Origin` header before checking the password. A cross-site context cannot read the GET response to learn the token, so it cannot forge a submission even with the password.

#### XPORT-009 — DCR `redirect_uri` accepts any URL scheme including `javascript:`/`data:`/`file:`
- Location: `src/transports/oauth-handlers.ts:202-209, 304-308`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: Registration validates `redirect_uris` only with `new URL(u)` which accepts `javascript:`, `data:`, `file:` and any custom scheme. The consent POST emits a 302 `Location:` to whatever scheme was registered. Most browsers refuse to navigate top-frame to `javascript:` via `Location:`, but `data:text/html,...` still loads with an opaque origin and `file://` can be used for local exfiltration on platforms that resolve it.
- Repro hypothesis: `curl -X POST .../oauth/register -d '{"client_name":"x","redirect_uris":["javascript:alert(1)"]}'` — succeeds. Repeat with `data:text/html,<script>...</script>` — succeeds.
- Suggested next step: Constrain registration to `http://localhost(:port)?/...`, `http://127.0.0.1...`, `https://...`, and well-known native-app loopback / custom-scheme patterns per the OAuth 2.1 native-apps BCP. Reject everything else with 400 `invalid_redirect_uri`.
> **Resolved in v3.0.56 — OAuth/transport hardening.** New `isAllowedRedirectUri()` enforces a scheme allowlist at DCR: `https` (any host), `http` for loopback hosts only (RFC 8252 §7.3), and reverse-DNS custom native-app schemes (RFC 8252 §7.1). `javascript:`/`data:`/`file:`/`blob:`/`vbscript:` and bare non-dotted schemes are rejected with 400 `invalid_redirect_uri`.

#### XPORT-015 — Plain HTTP allowed with arbitrary bind host; no warning at startup
- Location: `src/transports/http.ts:156-181`, `src/index.ts:2058-2073`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: Nothing checks the combination of `host !== "127.0.0.1"` and missing TLS cert. The docstring hints "Use 0.0.0.0 for LAN exposure" but no warning fires when the operator binds publicly without TLS. Static bearer goes over the wire in plaintext; an OAuth `access_token` returned at `/oauth/token` does the same. The OAuth issuer URL is derived as `http://0.0.0.0:8788` in that case — `0.0.0.0` is unroutable but lands in the RFC 8414 metadata, breaking the protected-resource discovery for any well-behaved MCP host.
- Repro hypothesis: Set `remoteHost: "0.0.0.0", remoteMode: true, remoteBearerToken: "x"` with no TLS — server starts; `/.well-known/oauth-authorization-server` reports `issuer: "http://0.0.0.0:8788"`.
- Suggested next step: At startup, log a high-severity warning (or refuse to start unless an `allowPlainHttpRemote` opt-in is set) when `host` is not loopback and `tlsCertPath`/`tlsKeyPath` are absent; reject `0.0.0.0` as an issuer host.
> **Resolved in v3.0.56 — OAuth/transport hardening.** `startHttpTransport` now logs a high-severity warning when serving auth over plain HTTP on a non-loopback bind (warn, not refuse — TLS-terminating proxies bind `0.0.0.0` legitimately), and substitutes loopback for `0.0.0.0`/`::` when deriving the issuer host so the RFC 8414/9728 metadata never advertises a wildcard address.

#### XPORT-004 — Token endpoint leaks resource/redirect/PKCE-mismatch via `invalid_target`
- Location: `src/transports/oauth-handlers.ts:343-359`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: The block at 347-353 deliberately collapses code/client/redirect/PKCE mismatches into one opaque `invalid_grant`, but the resource-indicator check at 356-359 returns the distinguishable `invalid_target` *only when the code and PKCE both validated*. That makes `invalid_target` a confirmed-good-code oracle.
- Repro hypothesis: Two parallel `/oauth/token` POSTs with the same code: the second receives `invalid_grant` because `consumeAuthCode` deleted the row; if it instead received `invalid_target`, the attacker would know the code was valid up to that point.
- Suggested next step: Collapse `invalid_target` into the same `invalid_grant` umbrella, or move the resource check before `consumeAuthCode`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The token-endpoint resource mismatch now returns the same opaque `invalid_grant` as the code/client/redirect/PKCE failures, so it is no longer a confirmed-good-code oracle.

#### XPORT-005 — Token verification skips constant-time compare; relies on Map hashing for token entropy
- Location: `src/transports/oauth-store.ts:162-171`
- Category: Security
- Severity: Low
- Confidence: Suspect
- Description: `verifyToken` does `this.tokens.get(token)` with the attacker-supplied token as the Map key. V8's hash-table comparison short-circuits on first byte mismatch, while everywhere else in the codebase goes out of its way to use `timingSafeEqual`. Token entropy (32 random bytes) makes brute force infeasible, so the practical risk is low, but the lack of consistency is a defense-in-depth gap.
- Repro hypothesis: Microbenchmark `Map.get(knownToken)` vs `Map.get(randomString)` over 10⁶ iterations; expect a small but measurable delta in V8.
- Suggested next step: Index tokens by SHA-256 hash so the user-supplied string is hashed first and the Map lookup compares fixed-length hashes; OR keep tokens linear-scanned with `constantTimeEqual`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `OAuthStore` keys both the `tokens` map and the `tokensByClient` reverse index by `sha256(token)`; the user-supplied bearer is hashed before any Map lookup, so the comparison is over fixed-length digests rather than the attacker-controlled string.

#### XPORT-010 — Health endpoint reveals OAuth-enabled flag without auth
- Location: `src/transports/http.ts:217-228`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: `/health` returns `{ "status": "ok", "transport": "streamable-http", "oauth": <bool> }` to anyone who can reach the port. This is enough to fingerprint mailpouch deployments at scale and to know whether `/oauth/register` is open.
- Repro hypothesis: `curl http://victim:8788/health` over the open internet.
- Suggested next step: Return `{"status":"ok"}` only; expose deployment detail behind authentication or remove it.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `/health` now returns `{ "status": "ok" }` only — no `transport` flavour, no `oauth` flag.

#### XPORT-011 — Missing security headers on transport responses
- Location: `src/transports/http.ts:213-326`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: None of the JSON or 401 responses set `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, or `Cache-Control: no-store`. The settings server explicitly sets all four at `src/settings/server.ts:534-553`.
- Repro hypothesis: `curl -v http://127.0.0.1:8788/mcp` — observe absent security headers compared to `curl -v http://127.0.0.1:<settings-port>/`.
- Suggested next step: Mirror the settings server's header set on every transport response, ideally via a helper applied uniformly at the listener entry.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** A `setSecurityHeaders(res)` helper applies `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, and `Cache-Control: no-store` at the top of the listener, so every transport response carries them.

#### XPORT-016 — OAuth `state` validation length-only; no character filtering
- Location: `src/transports/oauth-handlers.ts:248, 292, 304-308`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: `state` is bounded to 500 chars and otherwise passed straight into the redirect URL via `URL.searchParams.set`. The URLSearchParams encoder neutralises CRLF for the Location header so header injection is blocked, but `state` is echoed back to whatever the client uses to look it up — an attacker who controls `redirect_uri` (their own registered client) can stuff a long state with the leaked code into their server logs.
- Repro hypothesis: `?state=%00%0Aevil` — accepted, lands in the auth code record, echoed back in the redirect.
- Suggested next step: Reject `state` containing characters outside `[\x20-\x7E]` and strip on echo.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Both the authorize GET and POST handlers reject a `state` that contains any character outside `[\x20-\x7E]`.

#### XPORT-017 — Resource indicator stored without validation
- Location: `src/transports/oauth-handlers.ts:294-302, 354-359`, `src/transports/http.ts:258-265`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: `resource` is read from the form/query without checking it is a valid absolute URL. An attacker who passes `resource=` (empty) gets it normalised to `undefined` and the binding is skipped; the per-token resource check at 260 short-circuits when `rec.resource` is falsy.
- Repro hypothesis: Drive OAuth without supplying `resource`. The issued token can be replayed against any future MCP endpoint mounted on the same host (multi-tenant case).
- Suggested next step: When OAuth is enabled, default `resource` to the canonical `${issuer}${path}` if not supplied, and parse-validate any caller-supplied value.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `handleAuthorizePost` now binds every issued code to a resource: a caller-supplied value must be a valid absolute http(s) URI (`normalizeResource`), and an absent/empty one defaults to the canonical `cfg.resource`, so the per-token resource check is never silently skipped.

#### XPORT-018 — Caller logging includes attacker-supplied `client_name` verbatim
- Location: `src/transports/http.ts:279-281`
- Category: Security
- Severity: Low
- Confidence: Likely
- Description: `callerClientName` is sourced from the DCR-registered `client_name`, which an attacker controls. It then flows into `runWithCaller` and any downstream audit log / notification. Logger output destined for an interactive terminal could include ANSI escapes via raw bytes in the name.
- Repro hypothesis: Register `client_name: "\x1b[2J\x1b[H attack"` — the tray notification and log lines clear the terminal screen.
- Suggested next step: Strip control characters from `client_name` at registration and again on any log/notification render.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Already closed by the DCR sanitiser shipped in #145: `sanitizeDcrClientName` strips ANSI escape sequences and `[\x00-\x1f\x7f]` control characters (and length-caps) at registration, so the `callerClientName` flowing into `runWithCaller` and downstream logs/notifications is already clean. (The chat-mention vector on the webhook render path is separately closed under UI-017.)

#### XPORT-021 — DCR endpoint has no per-IP grant cap; pending-grant flood possible
- Location: `src/transports/oauth-handlers.ts:211-228`, `src/transports/http.ts:196-200`
- Category: Security
- Severity: Low
- Confidence: Likely
- Description: Each successful DCR call fires `agentGrants.createPending(...)` via the hook. Rate-limit allows 40-burst per IP. An attacker can register hundreds of pending grants per minute, each surfacing in the settings UI / notifications.
- Repro hypothesis: Loop `curl /oauth/register` from a single IP — observe the settings UI grant list fill.
- Suggested next step: Cap pending-grant creation per IP per hour separately from the burst limiter, and dedupe by `client_name` + IP.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `AgentGrantStore.createPending` now caps the not-yet-reviewed queue at `MAX_PENDING_GRANTS` (50), evicting the oldest pending grant(s) before admitting a new one, so a registration flood can't grow the settings-UI list without bound. (The DCR hook lacks the caller IP, so the cap is enforced on the pending queue rather than per-IP.)

#### XPORT-013 — `tokensByClient` reverse-index counter overcount on stale set
- Location: `src/transports/oauth-store.ts:149-159, 184-193`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `revokeTokensForClient` doesn't check that `this.tokens.delete(token)` returned true before incrementing `n`. The counter reported in the log may overcount when the set is stale relative to the primary map.
- Repro hypothesis: Manually corrupt the index in a unit test, call `revokeTokensForClient`, observe `n` higher than `verifyToken`-reachable tokens removed.
- Suggested next step: Have `revokeTokensForClient` only increment when delete returns true, and add an invariant test asserting `tokensByClient` size equals `tokens.size` after a sweep.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Already fixed in code since #126 — `revokeTokensForClient` increments `n` only inside `if (this.tokens.delete(token))`. v3.0.63 adds the invariant test (`oauth-store.test.ts` "XPORT-013 — revokeTokensForClient leaves the reverse index consistent").

#### XPORT-014 — `extractBearer` regex collapses whitespace; trailing-whitespace handling inconsistent
- Location: `src/transports/http.ts:90-95`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `/^Bearer\s+(\S+)$/i.exec(raw.trimEnd())` accepts any whitespace between "Bearer" and the token but rejects an extra trailing space because `trimEnd()` only strips the end. RFC 6750 requires exactly one space; the laxity is harmless but masks malformed clients.
- Suggested next step: Match RFC 6750 exactly: `/^Bearer (\S+)\s*$/` and document the strictness.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `extractBearer` now matches `/^Bearer ([^\s]+)\s*$/i` — exactly one SP between the scheme and the token, optional trailing whitespace.

#### XPORT-012 — Subscription leak when `oauthEnabled` is set but admin password missing
- Location: `src/transports/http.ts:182-205`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `notifications.subscribe(...)` is registered at line 186 before the OAuth-without-password sanity check at 203. When the check throws, `unsubGrantChanges` is never returned to the caller and the subscription handle is orphaned.
- Repro hypothesis: Call `startHttpTransport({oauthEnabled:true, oauthAdminPassword: undefined, bearerToken:"x", ...})` — observe the throw at 204; inspect the `notifications` listener list.
- Suggested next step: Move the `oauthEnabled && !adminPassword` validation above the `notifications.subscribe` call.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The `oauthEnabled && !oauthAdminPassword` throw now runs before `notifications.subscribe(...)`, so the subscription handle is never created when the config is rejected.

#### XPORT-019 — Tests assert behavior with no host warning for non-loopback bind
- Location: `src/transports/http.test.ts:80-100`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: The test suite covers `127.0.0.1` flows only; no test asserts behavior when `host: "0.0.0.0"` and no TLS is configured.
- Suggested next step: Add a startup-warning assertion paired with the fix in XPORT-015.

> **Acknowledged in v3.0.64 — by design: deferred with XPORT-015.** The non-loopback startup-warning assertion is meant to land paired with the XPORT-015 warning, which is not in this batch's scope.

#### XPORT-020 — `runWithCaller` doesn't include OAuth scope/resource on the caller context
- Location: `src/transports/http.ts:309-317`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: The caller context carries `clientId`, `clientName`, `ip`, `staticBearer` but drops `rec.scopes` and `rec.resource`. Per-tool gating cannot enforce scope-narrow tokens.
- Suggested next step: Either remove `SCOPES`/`scope` from the OAuth protocol surface (don't advertise what isn't enforced) or carry the granted scope into the caller context.

> **Acknowledged in v3.0.64 — by design: scopes are advertised, not enforced, intentionally.** The single-tier MCP gate does not narrow on OAuth scope/resource today; carrying them onto the caller context would imply enforcement that does not exist. No regression — the gate already keys on agent grant + preset.

#### XPORT-022 — Naming nits in transport
- Location: `src/transports/http.ts:88`, `oauth-handlers.ts:99`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: Two `const safeEqual = constantTimeEqual` / `const tokenMatches = constantTimeEqual` aliases serve no purpose and confuse search. `clientIp(req)` is recomputed multiple times.
- Suggested next step: Drop the aliases, reuse `caller` in `runWithCaller`.

> **Acknowledged in v3.0.64 — by design: readability aliases.** `safeEqual`/`tokenMatches` document intent at the call site; recomputing `clientIp(req)` is cheap and avoids threading state. Cosmetic only.

**TRANSPORTS-REMOTE patterns:** the transport layer is unusually thoughtful — PKCE shape validation, RFC-correct content-type handling, single-use auth codes, opaque error collapsing on the token endpoint, IP-pinned tokens, grant-revocation propagation through the notifications bus. Where it falls short is consistency of trust model across files. Three different places compute the "real client IP" with three slightly different rules. The DCR endpoint trusts whatever client_name and redirect_uri the network gives it but the consent page surfaces both as if they were vetted. Several "defence in depth" controls the settings UI already does (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, no-store) were never carried over to the transport. And the static-bearer path is treated as a footnote — single shared bucket, no per-IP keying.

### Permissions, escalation, agent grants

#### PERM-002 — Escalation approval applies globally, not to the requesting agent
- Location: `src/settings/server.ts:1319-1329`, `src/permissions/escalation.ts:339-365`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `approveEscalation` returns only `{ targetPreset }`; the settings-server route then does `cfg.permissions = buildPermissions(result.targetPreset); saveConfig(cfg);`. The challenge record never captures which OAuth client requested the escalation, so a human approving challenge X (intended for Agent A) silently raises the global preset ceiling for every connected agent. In a multi-agent install, any other agent whose grant intersects with `globalPreset` (see grant-manager.ts:91 `intersectPresets(grant.preset, ctx.globalPreset)`) instantly gains the wider ceiling for free.
- Repro hypothesis: Approve clients A (`send_only`) and B (`full`). A calls `request_permission_escalation("full", ...)`, human approves to widen global. B's effective preset now bumps because `intersect(full, full) = full`.
- Suggested next step: Record `clientId` on `EscalationRecord` at request time; on approval, only widen the per-agent grant for that client (or update the global ceiling AND log every grant whose effective preset shifted). At minimum, surface the requester in the approval card.
> **Resolved in v3.0.50 — Batch 5 (minimum fix).** `EscalationRecord` now carries `requestedByClientId` + `requestedByClientName` (control-char-stripped, length-capped); dispatcher threads it through. The deeper per-agent widening (vs. global) remains a follow-up but the information asymmetry is closed — operators see who requested the escalation before approving.

#### PERM-003 — Per-tool rate-limit bypass via `bulk_delete` ↔ `bulk_delete_emails` alias
- Location: `src/tools/deletion.ts:138-139`, `src/permissions/manager.ts:140-156`, `src/config/loader.ts:109-112`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `bulk_delete` and `bulk_delete_emails` resolve to the same `bulkDeleteHandler` but the gate keys rate buckets by tool NAME (`this.rateBuckets.get(tool)`). In the `supervised` preset both names get an independent cap of 20/hour. An agent can therefore delete 40 batches/hour (20 via `bulk_delete` + 20 via `bulk_delete_emails`), doubling the destruction throughput the operator configured.
- Repro hypothesis: Set preset=supervised. Issue 20 `bulk_delete_emails` calls back-to-back; the 21st returns the rate-limit reason. Now issue `bulk_delete` with the same payload — it succeeds, because the bucket key is the literal "bulk_delete" string.
- Suggested next step: Canonicalize aliases before `consumeRateSlot`. Maintain an `ALIAS_MAP` (e.g. `{ bulk_delete: "bulk_delete_emails" }`) and look up the canonical name for both the rate bucket and the destructive-confirm gate.
> **Resolved in v3.0.50 — Batch 5.** New `TOOL_ALIASES` + `canonicalToolName()` in `src/config/schema.ts`; applied at the rate-bucket lookup, per-tool enabled flag, and destructive-confirm gate.

#### PERM-004 — `move_email` bypasses destructive-confirm via `targetFolder: "Trash"`/`"Spam"`
- Location: `src/config/schema.ts:408-419`, `src/tools/actions.ts:82-100`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `DESTRUCTIVE_TOOLS` contains `move_to_trash` and `move_to_spam` but NOT `move_email`/`bulk_move_emails`, despite their identical effect when `targetFolder === "Trash"` (or `"Spam"`). The dispatcher's check keys off tool name only — `move_email` proceeds without `confirmed:true` and without elicitation. Same for `bulk_move_emails`.
- Repro hypothesis: With `requireDestructiveConfirm` true, call `move_to_trash` → blocked. Call `move_email { targetFolder: "Trash" }` with the same emailId → succeeds, no preview, no confirm.
- Suggested next step: In the destructive-gate, additionally check `args.targetFolder`/`args.folder` against destructive destinations (Trash, Spam) for `move_email`, `bulk_move_emails`, `move_to_folder`. Or add those tools to `DESTRUCTIVE_TOOLS` unconditionally.
> **Resolved in v3.0.50 — Batch 5.** New `MOVE_TOOLS_WITH_DESTRUCTIVE_TARGET` + `DESTRUCTIVE_DESTINATIONS` in `src/config/schema.ts`; the dispatcher destructive gate checks `args.targetFolder`/`args.folder` against the destination set (case-insensitive) for the three movers.

#### PERM-001 — Per-call escalation tools bypass agent-grant, permission, destructive-confirm, AND audit log
- Location: `src/index.ts:482-484`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: The dispatcher dispatches `request_permission_escalation` and `check_escalation_status` before account routing, before `grantManager.check`, before `permissions.check`, before the destructive-confirm gate, and outside the audit-log try/finally. A revoked/expired/pending agent can still spam escalation requests, and none of those requests appear in `agentAudit`. Only the global `~/.mailpouch.audit.jsonl` records them.
- Repro hypothesis: Approve client A, revoke client A. From A's OAuth token call `request_permission_escalation`. The MCP returns the challenge id and `~/.mailpouch.audit.jsonl` shows the requested event, but `~/.mailpouch-agent-audit.jsonl` shows nothing.
- Suggested next step: Run the agent-grant gate (status check + IP pin + rate-limit) on escalation tools too, with a denial path that still allows the call through but writes an audit row tagged `tool: "request_permission_escalation"`.

> **Resolved in v3.0.57 — permission/grant correctness.** The dispatcher now wraps the escalation-handler dispatch and writes an `agentAudit` row (ok / blockedReason + duration) for OAuth callers, so escalation requests are attributable in `~/.mailpouch-agent-audit.jsonl`. The tools still bypass the grant/permission/destructive gates by design — they can request, never grant.

#### PERM-005 — Default-allow when tool name is missing from the loaded `tools` map
- Location: `src/permissions/manager.ts:59-64`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: `if (!perm) return { allowed: true }` — when `config.permissions.tools[tool]` is undefined the gate fails open. Today no `ToolName` resolves to undefined because the loader merges defaults, but the dispatcher passes `name as ToolName` from `request.params.name` (arbitrary string), and any NEW tool added to the registry but not yet to `ALL_TOOLS` gets the default-allow treatment instead of default-deny.
- Repro hypothesis: Add a tool `pass_create` to `pass.ts` handlers + registry but forget to append it to `ALL_TOOLS`. With preset=read_only, `pass_create` succeeds because the gate's `perm` is undefined.
- Suggested next step: Treat undefined `perm` as DENY: `if (!perm) return { allowed: false, reason: "...not registered..." }`. Or assert at server-start that every key in `_toolHandlers` exists in `ALL_TOOLS`.

> **Resolved in v3.0.57 — permission/grant correctness.** `PermissionManager.check` now returns `{ allowed: false, reason: "…not a registered tool…" }` when the (canonicalized) tool resolves to no permission entry, instead of failing open.

#### PERM-006 — Audit log + pending-file rename not lock-protected across processes
- Location: `src/permissions/escalation.ts:194-216`, `src/agents/audit.ts:41-48`
- Category: Concurrency
- Severity: Medium
- Confidence: Likely
- Description: `savePendingFile` does load → mutate → atomic-rename without an advisory lock. The MCP server and the settings server can both write. Two racing `loadPendingFile`/`savePendingFile` cycles will silently drop one of the records — last writer wins on the rename(2).
- Repro hypothesis: Spawn `npm run settings` as a separate process and simultaneously have the MCP server expire a pending record while the settings server approves it. Final pending file reflects only one mutation.
- Suggested next step: Use a sibling `.lock` file with `flock(2)` (or `proper-lockfile`) around load→save.

> **Resolved in v3.0.57 — permission/grant correctness.** New `withFileLock` util (sibling `${target}.lock` dir, atomic `mkdir` acquire + stale-break, no new dependency) wraps the load→mutate→atomic-rename cycles in `escalation.ts` (request/approve/deny/status/pending) and `grant-store.ts` (create/approve/deny/expire/prune). The grant store additionally reload-merges grants another process created so a whole-file rewrite no longer drops them.

#### PERM-007 — Race between gate decision and tool execution allows TOCTOU widening
- Location: `src/index.ts:540-573` (gate) vs `src/index.ts:706-741` (handler)
- Category: Concurrency
- Severity: Medium
- Confidence: Likely
- Description: The grant gate reads `globalPreset` via `loadConfig()` at line 539; the permission gate reads `cachedConfig` via `permissions.check` at line 573; the destructive-confirm gate calls `loadConfig()` AGAIN at line 605. Each can return a different snapshot. If the user clicks "Approve" between line 540 and line 605, the agent-grant gate enforces the old preset while the destructive-gate observes the new one.
- Repro hypothesis: Set preset=read_only, agent grant=full. Agent calls `delete_email`. Human approves an escalation that flips global → full mid-call. Behavior is determined by interleaving.
- Suggested next step: Snapshot `loadConfig()` once at the top of the CallTool handler and pass the snapshot into all three sub-gates.

> **Resolved in v3.0.57 — permission/grant correctness.** The handler takes one `configSnapshot = loadConfig() ?? defaultConfig()` near the top; the agent-grant gate's `globalPreset` and the destructive-confirm gate's `requireDestructiveConfirm` both read that snapshot, closing the TOCTOU window. (The permission gate's 15 s `cachedConfig` is a separate intentional hot-reload path.)

#### PERM-008 — Static-bearer token treated as fully trusted: no per-agent grant check, no audit row
- Location: `src/index.ts:517, 746-757, 769-781`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: The dispatcher gates `if (caller && !caller.staticBearer)`. A request authenticated with the shared static bearer skips the grant store entirely and never appears in `~/.mailpouch-agent-audit.jsonl`. If the bearer leaks, all 70 tools execute with zero per-agent attribution.
- Repro hypothesis: Configure `remoteBearerToken` and call any tool with that token. Tail the agent audit JSONL — no row written.
- Suggested next step: Either disable the static-bearer path entirely when OAuth is enabled, or treat static-bearer as a synthetic clientId (`bearer:static`) with a mandatory pre-provisioned grant.

> **Resolved in v3.0.57 — permission/grant correctness.** The HTTP transport now refuses the static bearer whenever OAuth is enabled (`!oauthHandlers` guard on the static-bearer branch); OAuth deployments must use per-client DCR tokens that are individually gated, audited, and revocable. The static bearer remains available only for non-OAuth deployments.

#### PERM-009 — `clientName` is attacker-controlled and rendered to settings UI
- Location: `src/transports/oauth-handlers.ts:212`, `src/agents/grant-store.ts:88`, `src/index.ts:234`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: DCR records `client_name: typeof body.client_name === "string" ? body.client_name : undefined`. There is no length cap, no character sanitization, no rejection of control characters. That value flows into `AgentGrant.clientName`, the approval-prompt notification body, the audit rows, and the settings UI grant cards.
- Repro hypothesis: POST `/oauth/register` with `client_name: "Claude Desktop‮ — approved"` or a 20 KB string.
- Suggested next step: Apply `sanitizeText(name, 80)` at registration; strip control chars; HTML-escape on every renderer.

> **Resolved in v3.0.57 — permission/grant correctness.** The registration side was already closed in v3.0.50 #145 (`sanitizeDcrClientName` strips ANSI/control chars + caps length). This batch closes the render side: the settings-UI grant/agent-list `esc()` helpers were a weak 3-replacement (`& < "`) and could pass `>`/`'` from `clientName`; they now escape all five (`& < > " '`), matching the strict `escHtml`.

#### PERM-011 — Folder allowlist bypassable via tools that don't expose a folder arg
- Location: `src/agents/grant-manager.ts:99-116, 153-174`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: `FOLDER_AGNOSTIC_TOOLS` enumerates email-ID-scoped mutators as agnostic. That short-circuits the allowlist check — but those tools DO operate on a specific folder (UIDs are folder-scoped, and the per-tool `sourceFolder` arg now carries that information after the 3.0.41 fix). A grant pinned to `folderAllowlist: ["INBOX"]` accepts `delete_email { emailId: ..., sourceFolder: "Archive" }`.
- Repro hypothesis: Approve a grant with `folderAllowlist: ["INBOX"]`. Call `delete_email { emailId: "42", sourceFolder: "Archive" }`. Expected blocked, actual allowed.
- Suggested next step: Drop email-ID-scoped tools from `FOLDER_AGNOSTIC_TOOLS` now that `sourceFolder` is plumbed; extend `extractFolderArg` to read `sourceFolder`.

> **Resolved (partial) in v3.0.57 — permission/grant correctness.** The four email-ID-scoped mutators whose service genuinely honors the folder (`delete_email`, `mark_email_read`, `star_email`, `get_email_by_id`) are removed from `FOLDER_AGNOSTIC_TOOLS`, and `extractFolderArg` now reads `sourceFolder`/`source_folder`. A folder-restricted grant enforces the allowlist against the call's `sourceFolder`; a call omitting it fails closed. **Residual:** `get_thread` and `download_attachment` remain folder-agnostic *by design* because gating them here would be false enforcement — `get_thread` assembles related messages from INBOX+Sent regardless of the seed folder, and `download_attachment` passes no folder to the IMAP service (UID resolved without a folder constraint). Honestly scoping those two requires service-level folder constraints (cross-folder thread filtering; attachment UID resolution pinned to `sourceFolder`) — tracked as a PERM-011 follow-up for a later batch rather than shipping a gate check the service doesn't back.

#### PERM-013 — `intersectPresets` ranks `custom` equal to `full`
- Location: `src/agents/grant-manager.ts:181-190`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: The rank table treats `custom` with rank 3 (same as `full`). When `grant.preset === "custom"` and `globalPreset === "supervised"`, `intersectPresets` returns "supervised" and the gate then calls `globalAllows("supervised", tool)` — using the supervised preset's enabled-map, completely ignoring the user's per-tool customizations.
- Repro hypothesis: Set globalPreset=custom with `delete_email.enabled=false`. Approve agent grant with preset=supervised. The agent deletes emails the user disabled.
- Suggested next step: Persist the per-tool overrides from a `custom` preset separately and re-apply them after `intersectPresets`. Or refuse to support `custom` as a `grant.preset`.

> **Resolved in v3.0.57 — permission/grant correctness.** `GrantManager.check` short-circuits a `custom` grant preset to its explicit `toolOverrides` (default-deny for un-overridden tools) before reaching `intersectPresets`, since `buildPermissions("custom")` is all-enabled and carries no real restriction. `intersectPresets` therefore never sees `custom` as the grant side; `custom` as the *global* ceiling stays at the top rank, with the live per-tool custom config still enforced by `PermissionManager.check`.

#### PERM-010 — `recordCall` mutates in memory but never flushes
- Location: `src/agents/grant-store.ts:148-157`, `src/index.ts:780`
- Category: Data-loss
- Severity: Low
- Confidence: Confirmed
- Description: `recordCall` increments `g.totalCalls` and updates `g.lastCallAt` but deliberately does NOT call `persist()`. The comment claims "A future sweep step can flush these updates" but `flushCounters()` is never invoked anywhere outside the test. Every server restart silently drops the call counts since the last grant mutation.
- Repro hypothesis: `grep flushCounters src` shows only the definition + test. Make 100 tool calls, restart the server, inspect `~/.mailpouch-agents.json`: `totalCalls` is whatever was persisted on the last status change.
- Suggested next step: Either persist every Nth call, or hook a periodic flush into the existing shutdown handler and a setInterval.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `src/index.ts` now calls `agentGrants.flushCounters()` on a 5-minute `setInterval().unref()` and again inside `gracefulShutdown`, so `totalCalls`/`lastCallAt` survive a restart.

#### PERM-012 — Audit log on rotation drops in-flight buffer between read and truncate
- Location: `src/agents/audit.ts:65-71`
- Category: Data-loss
- Severity: Low
- Confidence: Likely
- Description: Rotation does `readFileSync` → `writeFileSync(.1.gz)` → `writeFileSync(path, "")` (truncation). Any concurrent `appendFileSync` between the read and the truncate is silently lost.
- Repro hypothesis: Pad the audit log to 10 MB − 100 bytes, then issue two concurrent tool calls. One landing between the read and the truncate disappears.
- Suggested next step: Open with `O_APPEND` and use `renameSync(path, .1)` + `gzip` of the renamed copy out-of-line — atomic on POSIX.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `rotateIfNeeded` now `renameSync`s the live file to a `.rotating` staging name first, gzips that copy out-of-line into `.1.gz`, then unlinks it. A concurrent `appendFileSync` lands in a fresh live file rather than the snapshot being truncated, closing the read→truncate loss window.

#### PERM-014 — `requestEscalation` race: two parallel callers each push a "pending" record
- Location: `src/permissions/escalation.ts:285-354`
- Category: Concurrency
- Severity: Low
- Confidence: Likely
- Description: `MAX_PENDING = 1` is enforced by counting pending records. With no lock, two concurrent invocations both pass the check, both append a record. The later write contains both; the invariant is violated.
- Repro hypothesis: Two clients calling `request_permission_escalation` near-simultaneously each get an ID back.
- Suggested next step: Wrap loadPendingFile→savePendingFile in a file-lock or in an in-process Mutex.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Already closed in code — `requestEscalation` wraps the entire load→check→save cycle in `withFileLock(getPendingFilePath(), …)` (the same lock that closes PERM-006), so two parallel callers serialize and the `MAX_PENDING` invariant holds. The inline comment already credits PERM-014.

#### PERM-015 — Supervised rate-limit prefix-match misses non-`bulk_` mass tools
- Location: `src/config/loader.ts:105-108`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: The supervised preset loops `if (tool.startsWith("bulk_"))` to set 100/hr. Any future tool with a non-`bulk_` prefix that bulk-acts (e.g. `mark_all_read`) won't be rate-limited by this loop.
- Suggested next step: Replace prefix matching with an explicit set of bulk tool names, or annotate `ToolPermission` with a `bulk: boolean` flag at registration time.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The supervised preset now iterates an explicit `SUPERVISED_BULK_ACTION_TOOLS` allowlist instead of `tool.startsWith("bulk_")`, so a future non-`bulk_`-prefixed mass tool must be added deliberately rather than slipping through unthrottled.

#### PERM-016 — `pass_get` is destructive-gated but `pass_search`/`pass_list` return names/URLs without confirm
- Location: `src/config/schema.ts:408-419`, `src/tools/pass.ts:42-60`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: Only `pass_get` requires `confirmed:true`. `pass_list`/`pass_search` are read-only by description but expose item names, URLs, and notes (Pass items frequently encode sensitive context in `name` and `note`). A grant scoped to a folder allowlist provides no protection — Pass has no folder concept.
- Suggested next step: Add a `passEnabled: boolean` to `GrantConditions` and require it for any `pass_*` call.

> **Acknowledged in v3.0.64 — by design: needs a new grant dimension.** Gating `pass_search`/`pass_list` behind a new `passEnabled` `GrantConditions` flag is a schema + grant-engine change, not a trivial Info fix; `pass_*` already requires the Pass integration to be configured and is off by default.

**PERMS-ESCALATION patterns:** the gate/grant subsystem was built two-tier: process-wide preset gate and per-agent grant gate. They don't share a config snapshot, alias map, or canonical-tool-name table, surfacing the same bug class in three places — `bulk_delete`/`bulk_delete_emails` (PERM-003), `move_email`/`move_to_trash` (PERM-004), and future-tool default-allow (PERM-005). All three are "the gate checks tool name but the handler checks behavior." A single canonicalization step before the gate would close all three. The escalation flow elides the identity of the requester (PERM-002, PERM-001). File-based shared state without locks (PERM-006, PERM-012, PERM-014). Per-agent counters tracked in memory but never persisted (PERM-010). Static-bearer/stdio paths treated as implicitly trusted (PERM-008).

### Tool surface (non-IMAP/non-SMTP)

#### TOOL-001 — `search_emails` blindly casts `args.folders` to `string[]` without runtime check
- Location: `src/tools/reading.ts:535-545`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: `const folders = args.folders as string[] | undefined` is a blind cast. When a client passes `folders: "INBOX"` (string), `folders.length === 1` evaluates `"INBOX".length === 1` → false; the for-loop then iterates per character, feeding single-char "I","N","B"... into `validateTargetFolder`. When `folders: {}` is passed, `folders.length` is `undefined`, the wildcard branch falls through, and the bogus value flows into `imapService.searchEmails`.
- Repro hypothesis: Call `search_emails` with `{ folders: "INBOX" }`. Either get a confusing "folders[0]: ..." validation error or a downstream IMAP failure.
- Suggested next step: Add `if (args.folders !== undefined && !Array.isArray(args.folders)) throw McpError(InvalidParams, ...)`.

> **Resolved in v3.0.53 — tool input hygiene.** `search_emails` now throws `McpError(InvalidParams)` when `folders` is present but not an array, before the per-element loop.

#### TOOL-002 — `request_permission_escalation` accepts missing `reason` despite schema declaring it required
- Location: `src/tools/escalation.ts:112-118`
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: `inputSchema.required: ["target_preset", "reason"]` but the handler does `sanitizeText((args.reason as string | undefined) ?? "No reason provided", 500)`. A client that omits `reason` gets a real escalation request silently logged as "No reason provided".
- Repro hypothesis: Call `request_permission_escalation` with `{ target_preset: "full" }` only. Watch the audit row land with `reason: "No reason provided"`.
- Suggested next step: Throw `McpError(InvalidParams, "reason is required and must be a non-empty string.")`.

> **Resolved in v3.0.53 — tool input hygiene.** Handler now routes `reason` through the shared `requireNonEmptyString()` before `sanitizeText`, so an omitted/blank reason throws `McpError(InvalidParams)`.

#### TOOL-003 — `get_contacts` accepts negative `limit` and passes it to `Math.min`
- Location: `src/tools/analytics.ts:220-233`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: `const contactLimit = Math.min((args.limit as number) || 100, limits.maxEmailListResults)`. The `|| 100` short-circuit only catches `0`, `NaN`, `undefined` — `-50` is truthy, so `Math.min(-50, maxEmailListResults)` returns `-50`. No `Math.max(1, ...)` clamp.
- Repro hypothesis: `get_contacts` with `{ limit: -5 }`.
- Suggested next step: Mirror `alias_list`'s pattern: `Math.min(Math.max(1, args.limit), limits.maxEmailListResults)`.

> **Resolved in v3.0.53 — tool input hygiene.** `contactLimit` now uses shared `clampOptionalInt(args.limit, 100, 1, maxEmailListResults)`; negatives and `NaN` collapse to a valid limit.

#### TOOL-004 — `get_volume_trends` does not validate `days` is positive/finite
- Location: `src/tools/analytics.ts:236-244`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: Handler only checks `typeof args.days !== "number"` and forwards the raw value. `-30`, `0`, `NaN`, `Infinity` all pass.
- Repro hypothesis: `get_volume_trends` with `{ days: -10 }` or `{ days: NaN }`.
- Suggested next step: Clamp: `Math.min(Math.max(1, Math.trunc(args.days)), 365)` before forwarding.

> **Resolved in v3.0.53 — tool input hygiene.** Handler clamps `days` via shared `clampOptionalInt(args.days, 30, 1, 365)` (only when provided); `-10`/`0`/`NaN`/`Infinity` no longer forward raw. Non-number `days` still throws `McpError(InvalidParams)`.

#### TOOL-008 — `get_correspondence_profile` may report "no prior correspondence" for known contacts
- Location: `src/tools/reading.ts:748-777`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: Handler asks `analyticsService.getContacts(500)` then linearly searches the returned array. If the mailbox holds more than 500 distinct correspondents, contacts ranked >500 silently become "no prior correspondence with X in the analytics window."
- Repro hypothesis: On a power mailbox with >500 contacts, query for a real but low-frequency correspondent. The handler returns `emailsSent: 0, emailsReceived: 0`.
- Suggested next step: Either bump the cap, or add a dedicated `analyticsService.findContact(email)` that scans the full set.

> **Resolved in v3.0.53 — tool input hygiene (tool-side guard; full fix deferred).** The handler no longer asserts "no prior correspondence" when the analytics top-500 scan cap is reached: the response now carries `exhaustive` (false when `getContacts(500)` returned a full 500-row page) and the message reads "not among the top 500 contacts … a lower-ranked record may exist". The exhaustive full-set fix (a `analyticsService.findContact(email)` accessor) lives in `src/services/analytics-service.ts`, owned by a sibling batch — tracked as a follow-up.

#### TOOL-025 — `get_email_by_id` mutates the cached `email` object before returning
- Location: `src/tools/reading.ts:524-528`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: When the body exceeds the limit, the handler does `email.body = email.body.substring(...) + "..."` directly on the object returned by `imapService.getEmailById`. If the IMAP service caches that object, the truncation persists into later calls.
- Repro hypothesis: Call `get_email_by_id` for a huge message with `maxEmailBodyChars = 10_000`, then raise the limit and call again — observe the same truncated body.
- Suggested next step: Clone before mutating (`const copy = { ...email, body: ... }`).

> **Resolved in v3.0.53 — tool input hygiene.** Oversized bodies are truncated on a shallow clone (`{ ...email, body: … }`); the object returned by `imapService.getEmailById` is never mutated, so a later call with a higher `maxEmailBodyChars` sees the full body.

#### TOOL-005 — `get_logs` NaN-propagates through Math.trunc/min/max
- Location: `src/tools/system.ts:229-232`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `rawLimit = typeof args.limit === "number" ? args.limit : 100`. If a caller sends `limit: Number.NaN`, `typeof NaN === "number"` so it bypasses the fallback. `Math.trunc(NaN) = NaN`, `Math.max(1, NaN) = NaN`, `Math.min(NaN, 500) = NaN`. `logger.getLogs(level, NaN)` ends up with a non-numeric limit.
- Suggested next step: Add `Number.isFinite(args.limit)` gate before treating as number.

> **Resolved in v3.0.53 — tool input hygiene.** `get_logs` now uses shared `clampOptionalInt(args.limit, 100, 1, 500)`, which gates on `Number.isFinite`; `NaN` collapses to the default instead of propagating through `Math.trunc/min/max`.

#### TOOL-006 — `alias_list` / `alias_get_activity` NaN pageSize passes the clamp
- Location: `src/tools/aliases.ts:173-176, 243-246`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `typeof args.pageSize === "number" ? Math.min(Math.max(1, args.pageSize), 1000) : 200`. `NaN` passes `typeof === "number"`. `Math.max(1, NaN) = NaN`. Outbound HTTPS query string contains `page_size=NaN`.
- Suggested next step: `Number.isFinite(args.pageSize)` predicate.

> **Resolved in v3.0.53 — tool input hygiene.** Both `alias_list` and `alias_get_activity` compute `pageSize` via shared `clampOptionalInt(args.pageSize, default, 1, 1000)`; `NaN` collapses to the default. (Note: `pageSize` is a caller-side cap on the SimpleLogin pagination loop, not an outbound `page_size` query parameter as the finding's description states — a `NaN` cap would have broken the `collected.length < pageSize` loop guard, not corrupted a query string. The clamp removes the hazard either way.)

#### TOOL-007 — `alias_create_custom` accepts empty-string `aliasPrefix`/`signedSuffix`
- Location: `src/tools/aliases.ts:197-208`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: Validates types but never trims/checks non-empty. `aliasPrefix: ""` and `signedSuffix: ""` pass validation and reach SimpleLogin, which returns an opaque 4xx.
- Suggested next step: Add `.trim() === ""` guard before the API call.

> **Resolved in v3.0.53 — tool input hygiene.** `alias_create_custom` runs `aliasPrefix`/`signedSuffix` through shared `requireNonEmptyString()`; empty or whitespace-only values throw `McpError(InvalidParams)` before any SimpleLogin call.

#### TOOL-009 — `fts_search` does not validate `limit`/`sinceEpoch` ranges
- Location: `src/tools/reading.ts:780-801`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `limit: typeof args.limit === "number" ? args.limit : undefined`. No range enforcement (1–200 per the input-schema description) and no `Number.isFinite` check.
- Suggested next step: Clamp at the handler boundary; mirror the explicit "1–200" bound.

> **Resolved in v3.0.53 — tool input hygiene.** `fts_search` clamps `limit` via shared `clampOptionalInt(args.limit, 20, 1, 200)` and rejects negative/`NaN` `sinceEpoch` with `McpError(InvalidParams)`, all at the handler boundary (fts-service.ts untouched — owned by a sibling batch).

#### TOOL-014 — `start_bridge` returns success-shaped result when ports never came up
- Location: `src/tools/bridge.ts:48-63`
- Category: UX
- Severity: Low
- Confidence: Confirmed
- Description: When `isBridgeReachable` returns false for either port, the handler returns `ok({ success: false, reason })` — no `isError: true`. The dispatcher's `ok()` wraps that as a normal tool result; an agent that only checks `result.isError` will read this as success.
- Suggested next step: Either flag with `isError: true` or document the convention so consumers always unpack `structuredContent.success`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `start_bridge` now returns `isError: true` when the SMTP/IMAP ports never become reachable, while still carrying `structuredContent.success: false` + reason for richer consumers.

#### TOOL-015 — `get_email_by_id`, `extract_action_items`, `extract_meeting`, `get_thread` cast `args.folder` without runtime type check
- Location: `src/tools/reading.ts:519, 722, 838, 849`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `args.folder as string | undefined` is a blind cast in four handlers. A caller sending `folder: 123` flows the number through to `imapService.getEmailById`.
- Suggested next step: Add an `args.folder !== undefined && typeof args.folder !== "string"` InvalidParams check.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** All four handlers (`get_email_by_id`, `extract_action_items`, `extract_meeting`, `get_thread`) now route `args.folder` through the shared `optionalFolderHint()` gate, which throws `McpError(InvalidParams)` on non-strings (this also closes VALID-001/009 — verified in this batch).

#### TOOL-016 — `search_emails` validates length only for `from`/`to`/`subject`, not `body`/`text`/`bcc`
- Location: `src/tools/reading.ts:546-622`
- Category: Defensive
- Severity: Low
- Confidence: Confirmed
- Description: `MAX_SEARCH_TEXT = 500` is enforced for `from`, `to`, `subject` but never for `body`, `text`, `bcc`. `larger`/`smaller` accept negatives and NaN.
- Suggested next step: Extend the 500-char clamp to all string predicates; clamp `larger`/`smaller` to `>= 0`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `body`/`text`/`bcc` were already 500-char capped in v3.0.58 (VALID-002); this batch adds the remaining piece — `larger`/`smaller` now reject negative and non-finite (NaN/Infinity) values with `McpError(InvalidParams)`.

#### TOOL-017 — `search_emails` coerces non-string `sentBefore`/`sentSince` via `new Date(args... as string)`
- Location: `src/tools/reading.ts:597-598`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `args.sentBefore ? new Date(args.sentBefore as string) : undefined`. The truthy-check accepts numbers, objects, etc.; `new Date(null)` → `Date(0)` (1970), `new Date({})` → Invalid Date.
- Suggested next step: Require `typeof === "string"` and validate `Date.parse(s)` is not `NaN`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `search_emails` now requires `sentBefore`/`sentSince` to be strings that `Date.parse` accepts, throwing `McpError(InvalidParams)` otherwise — no more `new Date(null)` → 1970 or `new Date({})` → Invalid Date.

#### TOOL-012 — Late-group reading tools use `email_id` while early group uses `emailId`
- Location: `src/tools/reading.ts:100, 266, 292, 420, 453`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `get_email_by_id` requires `emailId` (camelCase) but `download_attachment`, `get_thread`, `extract_action_items`, `extract_meeting` require `email_id` (snake_case). Same registry, two spellings.
- Suggested next step: Accept both as input on each handler and pick one for the schema.

> **Acknowledged in v3.0.62 — by design:** the two spellings are a frozen part of the published tool schema; renaming `email_id` → `emailId` (or vice versa) is a breaking change to every existing agent prompt/integration, and accepting both as input aliases would add per-handler ambiguity for a cosmetic naming difference. The shared `requireNumericEmailId(args.X, "X")` already gives both spellings identical validation/error shapes, which is the substance of the concern.

#### TOOL-013 — `fts_rebuild` `_ftsRebuilding` flag is module-global, breaks multi-account
- Location: `src/tools/reading.ts:29, 803-819`
- Category: Concurrency
- Severity: Low
- Confidence: Confirmed
- Description: Per-account routing means two simultaneous `fts_rebuild` calls against different accounts share one boolean. The second caller gets "rebuild already in progress" even though its target index could be a totally different file.
- Suggested next step: Key the flag by `accountId` (or by the resolved `dbPath`).

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `_ftsRebuilding` is now a `Set<string>` keyed by the resolved `fts.stats().dbPath`; concurrent rebuilds against different account indexes no longer collide, while a second rebuild against the SAME DB is still rejected.

#### TOOL-020 — `shutdown_server`/`restart_server` audit row may be lost
- Location: `src/tools/bridge.ts:65-83`, `src/index.ts:763-781`
- Category: Tech-debt
- Severity: Low
- Confidence: Suspect
- Description: The handler returns synchronously and schedules `gracefulShutdown` via `setImmediate`. The dispatcher's `finally` block then writes a success audit row — fire-and-forget. The audit append may be cut mid-write or skipped entirely.
- Suggested next step: Either await the audit write before `setImmediate`, or delay `gracefulShutdown` slightly.

> **Acknowledged in v3.0.62 — by design:** no fix needed. `agentAudit.write()` is a synchronous `appendFileSync` (agents/audit.ts) that runs in the dispatcher's `finally` block. `setImmediate(gracefulShutdown)` defers the shutdown to the NEXT event-loop iteration, which cannot start until the current synchronous stack — including the `finally` audit append — has fully unwound. The audit row is therefore always written before shutdown begins; the "cut mid-write / skipped" hypothesis (Suspect) does not hold for a sync append + `setImmediate` deferral.

#### TOOL-010 — `get_thread` outputSchema omits `messages` field shape match
- Location: `src/tools/reading.ts:298-309` vs handler `719-746`
- Category: Tech-debt
- Severity: Low
- Confidence: Likely
- Description: outputSchema declares `messages: { items: EMAIL_SUMMARY_SCHEMA }` (summary fields only), but the handler returns full `EmailMessage` objects. No body-size truncation here either, so a long thread can blow the 1 MB response budget.
- Suggested next step: Either narrow the returned objects before `ok()` or widen the schema.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `get_thread` now maps each message to the `EMAIL_SUMMARY_SCHEMA` shape (deriving a 300-char `bodyPreview` from the body) before `ok()`, matching the declared schema AND bounding the response so a long thread can't blow the 1 MB budget.

#### TOOL-011 — `get_emails_by_label` outputSchema item type is a bare `object`
- Location: `src/tools/reading.ts:244-252`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `outputSchema.properties.emails.items: { type: "object" }` — no properties. The sibling tool `get_emails` advertises `EMAIL_SUMMARY_SCHEMA` for the same items.
- Suggested next step: Replace with `items: EMAIL_SUMMARY_SCHEMA` and add `required: ["emails", "folder", "count"]`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `get_emails_by_label`'s outputSchema now uses `items: EMAIL_SUMMARY_SCHEMA` (matching `get_emails`) and declares `required: ["emails", "folder", "count"]`.

#### TOOL-018 — `get_connection_status` outputSchema has no `required` array
- Location: `src/tools/system.ts:31-68`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: Most other handlers' outputSchemas declare `required`; this one does not. If a refactor drops `imap` or `settingsConfigPath`, the schema validator won't catch it.
- Suggested next step: Add `required: ["smtp", "imap", "settingsConfigured", "settingsConfigPath"]`.

> **Resolved in v3.0.64 — Info triage.** Added `required: ["smtp","imap","settingsConfigured","settingsConfigPath"]` to the `get_connection_status` outputSchema so a dropped field fails validation.

#### TOOL-019 — `fts_status` outputSchema missing `required: ["available"]`
- Location: `src/tools/reading.ts:400-409`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: `available` is always populated by the handler but the schema does not list it as required.
- Suggested next step: `required: ["available"]`.

> **Resolved in v3.0.64 — Info triage.** Added `required: ["available"]` to the `fts_status` outputSchema.

#### TOOL-021 — `pass_get` outputSchema's `fields` map doesn't allow undefined
- Location: `src/tools/pass.ts:73-85`
- Category: Tech-debt
- Severity: Info
- Confidence: Likely
- Description: outputSchema `properties.fields: { type: "object", additionalProperties: { type: "string" } }` but doesn't allow `null`/`undefined`. Clients reading the schema will assume `fields` always-or-never present.
- Suggested next step: Document the absence semantics in the schema description.

> **Resolved in v3.0.64 — Info triage.** Added a schema description on `pass_get`'s `fields` documenting that it is omitted when the entry has no extra fields, so clients treat it as optional.

#### TOOL-022 — `check_escalation_status` `not_found` branch returns no `isError`
- Location: `src/tools/escalation.ts:174-179`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: When no record exists the handler returns `{ status: "not_found" }` only, without `isError: true`. An agent polling with a stale challenge_id may treat "not_found" as success and keep polling forever.
- Suggested next step: Either flag `isError: true` for the not_found branch or document explicitly that polling should stop on `status: "not_found"`.

> **Acknowledged in v3.0.64 — by design: not_found is already terminal.** The `check_escalation_status` not_found branch returns a clear `No escalation found with ID '<id>'.` text body and `status: "not_found"`; flagging `isError: true` would mislabel a successful status query as a tool failure. The text content is the documented stop signal.

#### TOOL-023 — `clear_cache` claims to "clear all in-memory caches" but does not invalidate FTS handle
- Location: `src/tools/system.ts:210-217`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: Description says "Clear all in-memory caches (email message cache, folder cache, analytics cache)". If "stale data" is the reason a user calls `clear_cache`, leaving FTS untouched means `fts_search` keeps returning stale results.
- Suggested next step: Add a sentence to the description noting FTS is not rebuilt.

> **Resolved in v3.0.64 — Info triage.** `clear_cache` description now states it does NOT rebuild the on-disk FTS index and points callers to `fts_rebuild`.

#### TOOL-024 — `get_emails_by_label` clamp order differs from `get_emails`
- Location: `src/tools/reading.ts:657` vs `491`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: `get_emails` uses `Math.min(Math.max(1, val), 200, ...)`. `get_emails_by_label` uses `Math.min(Math.max(val, 1), 200, ...)` — argument order divergence.
- Suggested next step: Mirror the `get_emails` form.

> **Resolved in v3.0.64 — Info triage.** `get_emails_by_label` clamp now uses the same `Math.min(Math.max(1, …), 200, …)` argument order as `get_emails`.

**TOOL-SURFACE patterns:** the tool surface is overall disciplined — runtime type checks are common, destructive-confirm coverage is solid, and the dispatcher's gate chain is well-ordered. The recurring weaknesses cluster around numeric input hygiene (NaN/negative/Infinity slip past `typeof === "number"` checks in `get_contacts`, `get_volume_trends`, `get_logs`, `fts_search`, `alias_list`, `alias_get_activity`), blind casts of `args.folder`/`args.folders`/`args.sentBefore` where neighbouring fields get full validation, and outputSchema laxness (missing `required:`, bare `{ type: "object" }` items, EMAIL_SUMMARY_SCHEMA not reused). The naming split between `emailId` and `email_id` is the most visible UX drift. The `start_bridge` "success-shaped failure" hints that the v3.0.41 anti-pattern lives in non-IMAP corners too.

### Credentials, crypto, multi-account

#### CRED-001 — `migrateCredentials()` migrates only `password` + `smtpToken`; `passAccessToken` and `simpleloginApiKey` remain plaintext on disk forever
- Location: `src/config/loader.ts:478-546` and `src/security/keychain.ts:237-279`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: The startup migration only inspects `connection.password` and `connection.smtpToken` (and remoteBearer/remoteOauthAdmin in `migrateFromConfig`). The Proton Pass Personal Access Token (`passAccessToken`) and the SimpleLogin API key (`simpleloginApiKey`) round-trip through `mergedConnection` (loader.ts:255) and live in `~/.mailpouch.json` as plaintext, even though `pass-service.ts:23-28` documents Pass tokens as decrypted-secret-access-grade and `schema.ts:262` calls out "Prefer keychain storage when available". `credentialStorage="keychain"` therefore lies whenever a Pass PAT or SimpleLogin key is set.
- Repro hypothesis: Configure a Pass PAT in the Settings UI on a host with libsecret; `~/.mailpouch.json` shows `"passAccessToken": "ya29.…"` in cleartext after `saveConfig`, while `credentialStorage` reads `"keychain"`.
- Suggested next step: Extend `migrateFromConfig` + `saveConfigWithCredentials` to route `passAccessToken` and `simpleloginApiKey` to keychain entries (`pass-pat`, `simplelogin-api-key`) and blank the fields on disk; mirror through `loadCredentialsFromKeychain`.
> **Resolved in v3.0.46 — Batch 4.** New `loadAuxiliaryCredentials` / `saveAuxiliaryCredentials` in `src/security/keychain.ts`; `migrateFromConfig` migrates Pass PAT and SimpleLogin key on startup; `saveConfigWithCredentials` routes them on Settings save; `loadAuxiliaryCredentialsFromKeychain` rehydrates the in-process clients at boot.

#### CRED-002 — `_homeFile()` honors env-var overrides with zero containment check (path traversal hole)
- Location: `src/index.ts:166-178`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `getConfigPath()` (loader.ts:44-58) carefully forbids `MAILPOUCH_CONFIG` from pointing outside `$HOME` ("must point to a path within the home directory"). But `_homeFile` — used for `MAILPOUCH_PASS_AUDIT`, `MAILPOUCH_SCHEDULER_STORE`, `MAILPOUCH_REMINDERS`, `MAILPOUCH_AGENT_AUDIT`, `MAILPOUCH_AGENTS`, `MAILPOUCH_FTS_DB` — returns `process.env[envName]` verbatim with no normalisation, no resolve, no `$HOME` check. An attacker who can set env can redirect the Pass audit log to `/etc/cron.d/foo`, the agent-grants store to `/var/www/html/grants.json`, or any other writable target — the file is then created with the credential-bearing JSON content.
- Repro hypothesis: `MAILPOUCH_PASS_AUDIT=../../tmp/x.jsonl mailpouch` — every `pass_get` call writes attacker-controlled JSON to `/tmp/x.jsonl` with mode 0600. The MCP's own threat model (loader.ts comment at line 48-49) treats env-driven path-traversal as in-scope.
- Suggested next step: Funnel every `_homeFile` consumer through the same `resolve + startsWith(home)` check as `getConfigPath`.
> **Resolved in v3.0.46 — Batch 4.** Extracted to `src/utils/home-path.ts`, applied containment check, throws on out-of-home paths so startup fails loudly.

#### CRED-003 — AES-GCM key derived via single SHA-256 over low-entropy inputs (no salt-stretching, no KDF)
- Location: `src/crypto/credential-encryption.ts:118-128`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `deriveKey` builds the 256-bit AES key from `sha256(machineId || hostname || APP_SALT || platform)`. The "salt" is a hard-coded constant string. There is no PBKDF2/scrypt/HKDF — anyone who reads the encrypted blob plus knows the machine-id can recompute the key with a single hash. machine-id is readable by any local user (`/etc/machine-id` is world-readable on Linux), so the AES-GCM ciphertext is effectively decryptable by every local UID once they grab the JSON. On the persisted-fallback branch (`~/.mailpouch-machine-id`, line 91-98) it is mode 0600, but that path is only taken when the OS source fails. Net effect: the "encrypted-file" mode protects against an attacker who has the file but not the host, not against a local attacker.
- Repro hypothesis: As any local user on Linux, `cat /etc/machine-id; cat ~chuck/.mailpouch.json` → reconstruct key with the same 4-line `deriveKey` → `decipher.setAuthTag(...)` → recover the Bridge password.
- Suggested next step: Replace with `scrypt`/`pbkdf2` over a per-file random salt (stored next to the blob), and document that "encrypted-file" only resists the bag-of-disks threat model; recommend keychain for any multi-tenant host.
> **Resolved in v3.0.46 — Batch 4.** v3 blob format introduces a per-blob 16-byte random salt + `scrypt(machine-id||hostname||platform, salt, N=16384, r=8, p=1)`. v1/v2 blobs remain readable; next `encrypt()` upgrades them to v3. "encrypted-file" still only resists bag-of-disks — keychain remains the multi-tenant recommendation.

#### CRED-006 — Auth-tag length not validated; truncated `authTag` is silently accepted by `setAuthTag`
- Location: `src/crypto/credential-encryption.ts:147-156`, `158-168`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: `isValidEncrypted` only checks `e.authTag.length > 0`. Node's `setAuthTag` accepts any tag length 4-16 bytes (with a deprecation warning at <12 bytes); a 4-byte tag has 2^32 forgery margin. An attacker who can write the config file but not the keychain (e.g. a leaked-XSS-on-settings-UI scenario, or shared-host config edit) could replace the `authTag` field with a 4-byte tag plus a chosen ciphertext to drastically narrow the forgery cost. Combined with CRED-003 this is academic for now, but the GCM contract is silently weakened.
- Repro hypothesis: Hand-craft a blob with a 4-byte authTag in the JSON; `CredentialEncryption.decrypt` accepts it without complaint.
- Suggested next step: Require `Buffer.from(authTag, "base64").length === 16` in `isValidEncrypted` and in `decrypt`.
> **Resolved in v3.0.59 — crypto/credential discipline.** `decrypt()` and `isValidEncrypted()` now require the auth tag to decode to a full 16-byte GCM tag; short/garbage/over-length tags are rejected before they reach `setAuthTag`. Real blobs (v1→v3) always carry a 16-byte tag, so the read-side migration is unaffected.

#### CRED-007 — `saveConfig` writes 0o600 only on file creation; pass-audit and machine-id fallback never re-assert mode
- Location: `src/config/loader.ts:350-364`, `src/services/pass-service.ts:197`, `src/crypto/credential-encryption.ts:97`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: `writeFileSync({mode: 0o600})` only sets the mode on file creation (it's the open(2) mode arg masked by umask, ignored if the file existed). The audit log `appendFileSync(this.auditPath, ..., {mode:0o600})` has the same flaw — mode only applies on creation. The logger has the correct pattern (`statSync` → `if (mode & 0o077) chmodSync(...0o600)` before each write); the pass audit and machine-id paths do not.
- Repro hypothesis: `chmod 644 ~/.mailpouch-pass-audit.jsonl`; mailpouch never re-asserts to 0600. Confirmed by the symmetric chmod-on-detect pattern that DOES exist in `logger.ts:144-147`.
- Suggested next step: Mirror the logger's pattern in pass-service audit and on the machine-id fallback.
> **Resolved in v3.0.59 — crypto/credential discipline.** `saveConfig` (config file) and the machine-id fallback now `statSync` → `chmodSync(0o600)` when the mode has drifted past `0o077`, mirroring the logger's chmod-on-detect pattern, instead of trusting the umask-masked `writeFileSync` creation mode. (Pass-audit re-assert is tracked separately under CRED-009.)

#### CRED-008 — `loadConfig` cache breaks under concurrent writers — no file-lock anywhere
- Location: `src/config/loader.ts:178-207`, `src/accounts/registry.ts:140-172`
- Category: Concurrency
- Severity: Medium
- Confidence: Likely
- Description: The 15s cache keys on `mtimeMs` of the config file, not the keychain. After `saveConfigWithCredentials` writes to the keychain and blanks the file fields, mtime updates and the cache is invalidated — fine. But `writeRegistry → saveConfig` and an independent settings-UI POST that races within 15ms can produce two near-simultaneous writes (no flock); the second `renameSync` clobbers the first. `invalidateConfigCache()` runs synchronously after rename, so any reader between the two renames may see a half-merged file with credentialStorage="keychain" and live plaintext for a different field. No file-lock anywhere in loader.ts or registry.ts.
- Repro hypothesis: Two tabs of the settings UI saving simultaneously (one changes username, one changes smtp token). Race window is large (entire JSON write + rename). Last writer wins, plus the cache can serve the loser briefly.
- Suggested next step: Take an exclusive lock on `${dest}.lock` (`O_EXCL` open or `proper-lockfile`) around the read-modify-write in saveConfig and writeRegistry.
> **Resolved in v3.0.55 — persistence atomicity.** Added `withConfigLock`/`withConfigWriteLockAsync` in `loader.ts`: an `O_EXCL` lock on `${config}.lock` with stale-lock reclamation, reentrancy (so the inner `saveConfig` reuses the outer lock), and always-release-in-finally, plus an in-process async mutex so concurrent same-process async writers can't deadlock the event loop. `saveConfig` and `writeRegistry`'s full read-modify-write now run under it. No new dependency.

#### CRED-010 — `loadCredentialsFromKeychain` falls through to plaintext config when encrypted-blob decryption throws — masks compromise indicators
- Location: `src/config/loader.ts:392-430`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: When `passwordEncrypted` is present and `decrypt(...)` throws (e.g. machine-id changed, tag tampering, version downgrade), the catch block silently sets `password = ""` and then proceeds. If the same config also has a plaintext `connection.password` (e.g. mid-migration state, or attacker-injected to coexist), the next branch (`config.connection.password || config.connection.smtpToken`) happily returns the plaintext — including from a blob that just failed GCM auth. The user is not warned that an authenticated blob failed.
- Repro hypothesis: Hand-edit `~/.mailpouch.json` to keep `passwordEncrypted` (with a corrupted tag) AND set `password: "attacker-known-string"`. On next start, mailpouch uses the attacker-known value, logs nothing higher than the "loaded from config file" debug line.
- Suggested next step: When `isValidEncrypted` is true but decrypt throws, surface a `logger.error` (or refuse to boot) and never fall through to plaintext from the same file; treat coexistence as a tamper indicator.
> **Resolved in v3.0.59 — crypto/credential discipline.** When a valid-shaped encrypted blob fails `decrypt()`, `loadCredentialsFromKeychain` emits a `logger.error` and returns a distinct `{ storage: "decrypt-failed" }` sentinel (not null — which the caller couldn't distinguish from "no credentials" and would answer by reading the plaintext field itself, re-opening the hole). The startup caller in `index.ts` checks that sentinel and refuses to fall back to the coexisting plaintext `connection.password`/`smtpToken`, leaving the credential empty (send/receive fails closed until re-encrypted). Plaintext + failed-auth blob is treated as tamper, not a migration fallback.

#### CRED-004 — `writeRegistry()` keychain-vs-plaintext detection brittle to future refactors
- Location: `src/accounts/registry.ts:140-172`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: `anyKeychain` is inferred from the scrubbed-account shape (`!a.password && !a.smtpToken`). For an account where the user only sets a password (no SMTP token), `saveAccountCredentials` returns true and the scrubbed spec becomes `{password:"", smtpToken: undefined}`. So the predicate holds today, but it's brittle: a future change that always coerces empty smtpToken to undefined would silently flip credentialStorage to "keychain" even on full plaintext fallback, giving the UI a false-clean badge.
- Repro hypothesis: Add a refactor that normalises `smtpToken ?? undefined` post-save; suddenly all keychain-failed hosts show "keychain" in the badge.
- Suggested next step: Track keychain success per-account in writeRegistry from the actual `saveAccountCredentials` return value instead of inferring it from the scrubbed shape.
> **Resolved in v3.0.59 — crypto/credential discipline.** `writeRegistry` now records each account's actual `saveAccountCredentials` result (`keychainSaved = hadSecrets && keychainOk`) and derives `credentialStorage` from that, instead of inferring keychain success from the scrubbed on-disk shape — so an empty-field normalization can no longer produce a false-clean "keychain" badge.

#### CRED-005 — `loadAccountCredentials` called twice per account in `readRegistryWithSecrets`
- Location: `src/accounts/registry.ts:78-105`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: The two `if (!acct.password)` and `if (!acct.smtpToken)` blocks each independently call `loadAccountCredentials(acct.id)`. When a per-account keychain entry has BOTH password and smtpToken, the first call's `smtpToken` field is read then thrown away; the second call re-fetches from the keychain (a sync FFI to libsecret/macOS-Keychain/Credential-Manager — not free). On a 5-account install that's 10 keychain entries fetched at every boot/registry-rebuild.
- Repro hypothesis: Instrument `loadAccountCredentials` with a counter; observe N=2×accounts per `rebuildFromRegistryAsync`.
- Suggested next step: Single call, destructure once: `const { password, smtpToken } = await loadAccountCredentials(acct.id) ?? {}`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `readRegistryWithSecrets` now calls `loadAccountCredentials(acct.id)` once per account (skipping accounts that already have both fields) and the legacy `loadCredentials()` fall-back is loaded lazily and cached per account.

#### CRED-009 — Pass audit log is append-only by file mode only — not tamper-evident
- Location: `src/services/pass-service.ts:190-201`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: The doc comment at line 22-23 calls the audit log "append-only", but the implementation is `appendFileSync(...)` with no chained hash, no integrity field, no log rotation, no fsync. A local attacker with write to `~/.mailpouch-pass-audit.jsonl` can rewrite history at will. For a file that records every `pass_get` (decrypted-credential access), that's a meaningful gap if the threat model includes a post-compromise forensic step.
- Repro hypothesis: `pass_get itemId=foo`; later `sed -i '/foo/d' ~/.mailpouch-pass-audit.jsonl` — no detection.
- Suggested next step: Add a hash-chained row (`prevHash + sha256(row)` written as a field), and document explicitly that the log is best-effort.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** Pass audit rows now carry `prevHash` + `hash` (`sha256(prevHash || rowSansHash)`), seeded lazily from the last on-disk row so the chain survives restarts. The class doc-comment now states the log is best-effort and tamper-EVIDENT (not tamper-proof): a writer with file access can recompute the whole chain.

#### CRED-011 — `re-encrypt v1 → v2` migration not atomic across the two credential fields
- Location: `src/config/loader.ts:493-518`
- Category: Correctness
- Severity: Low
- Confidence: Suspect
- Description: If `pwNeedsReencrypt` is true and the `decrypt` succeeds but `encrypt` then throws (OOM, weird), the catch swallows it and leaves the config inconsistent (smtp blob possibly re-encrypted, password blob still v1). No atomic save-or-rollback.
- Repro hypothesis: Inject a throw in the second `encrypt`; observe a config where one credential is v2 and the other v1.
- Suggested next step: Stage both new blobs in locals, then assign both and save once; or wrap in a try/restore.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** The v1→v2 re-encrypt path now stages both new blobs in locals and only assigns + `saveConfig` once BOTH encrypts succeed; if the second throws, neither field is mutated and nothing is persisted (regression-tested).

#### CRED-012 — SimpleLogin error path echoes upstream JSON verbatim — token leak risk
- Location: `src/services/simplelogin-service.ts:74-108`
- Category: Security
- Severity: Low
- Confidence: Suspect
- Description: When the API returns an error, the code throws `Error("SimpleLogin GET /api/foo → ${parsed.error}")`. SimpleLogin's `error` payload is server-controlled; nothing in the chain prevents it from echoing back the auth header for diagnostics on certain endpoints. The thrown error reaches the MCP response and the logger; the logger's redaction is by *key*, not by value-pattern, so an `error` string carrying the API key would pass straight through to `~/.mailpouch.log`.
- Repro hypothesis: A future SimpleLogin error message of the form `"invalid key 'sl_xxx'"` would land in the JSONL log unredacted.
- Suggested next step: Defensively redact known-token patterns in the error path; or strip the upstream body to a status code + generic message before raising.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** The error path now runs `redactSecrets()` on the upstream message before raising: it strips the exact configured API key plus any opaque token-shaped substring (24+ chars of base62/`_-`), replacing each with `[redacted]` so a hostile `error` body can't smuggle a secret into the log or MCP response.

#### CRED-013 — Pass CLI child inherits `cwd`; pass-cli may write to attacker-writable dir
- Location: `src/services/pass-service.ts:138-149`
- Category: Tech-debt
- Severity: Low
- Confidence: Suspect
- Description: The env restriction is good hygiene. But `cwd` is inherited (default `process.cwd()`), which could be an attacker-writable directory (e.g. when launched from `/tmp`). pass-cli may write its session-state file relative to `cwd`.
- Repro hypothesis: `cd /tmp && mailpouch`; later inspect what pass-cli dropped into `/tmp`.
- Suggested next step: Add `cwd: homedir()` to the spawn options.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** The pass-cli `spawn` options now pin `cwd: homedir()` so the child never inherits an attacker-writable `process.cwd()` (e.g. `/tmp`).

#### CRED-015 — `migrateCredentials()` does not deep-clone the config before mutate-and-save
- Location: `src/config/loader.ts:478-545`
- Category: Concurrency
- Severity: Low
- Confidence: Likely
- Description: `migrateCredentials` calls `loadConfig()` (line 481), mutates the returned object, then `saveConfig(config)` (line 509/542). `loadConfig` populates `_configCache` with the read result. Between read and save, any concurrent caller within 15s gets the *in-flight, partially-mutated* config object from cache (same reference). Since the mutation now blanks `password` and adds `passwordEncrypted` before save returns, a racing `loadCredentialsFromKeychain` could observe `password=""` + missing encrypted blob and conclude "no credentials" — a transient boot failure.
- Repro hypothesis: At boot, fire `migrateCredentials()` + `loadCredentialsFromKeychain()` in parallel; intermittent "no credentials" on the second path.
- Suggested next step: `loadConfig` should return a deep-cloned object so callers can't see in-flight mutations; or take the lock from CRED-008.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `migrateCredentials` now runs its whole read-modify-write inside `withConfigWriteLockAsync` (the CRED-008 lock) and operates on a `structuredClone` of the loaded config — the cache keeps the un-mutated reference until `saveConfig` invalidates it, so a racing reader never sees `password=""` + missing blob mid-migration.

#### CRED-014 — `SecureBuffer.toString()` allocates GC-tracked string, defeating wipe guarantee
- Location: `src/security/memory.ts:20-45`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: As soon as `toString()` is called, V8 allocates an immutable string that `wipe()` cannot reach. The class docstring already concedes this, but every call site that uses `SecureBuffer` for an SMTP password effectively duplicates the secret into a GC-managed string the first time it's consumed — so wipe at shutdown leaves N copies in the heap depending on how many transports reused the string. This is "as documented" but worth flagging as defense-in-depth that the multi-account refactor sidesteps entirely (it stores plaintext strings on `AccountSpec.password`).
- Repro hypothesis: Heap-snapshot after `applyKeychainCredentials` — multiple copies of the same secret live in `(string)` constants.
- Suggested next step: Either embrace strings throughout (and drop SecureBuffer), or push Buffer-only through the whole SMTP/IMAP stack so `wipe` actually means wipe.

> **Acknowledged in v3.0.64 — by design: documented tradeoff.** `SecureBuffer.toString()`'s GC-string leak is conceded in the class docstring; eliminating it requires pushing Buffer-only through the whole SMTP/IMAP stack (or dropping SecureBuffer entirely), a cross-cutting refactor outside Info-triage scope.

**CREDS-CRYPTO patterns:** the credential surface has the right shape (keychain > encrypted-file > plaintext, with migration), but the encryption path is window-dressing — `sha256(machine-id || hostname || const-salt || platform)` plus a world-readable machine-id means any local UID recovers the AES key trivially. Secret discipline is uneven across credential types: bridge password and SMTP token are migrated to keychain, but Pass PAT and SimpleLogin API key — the higher-value secrets — sit in `~/.mailpouch.json` as plaintext while `credentialStorage="keychain"` claims otherwise. Defensive checks exist around `MAILPOUCH_CONFIG` but were not propagated to the parallel `_homeFile` env-driven overrides (CRED-002). File-mode hygiene is inconsistent — the logger re-asserts 0600, the Pass audit log and machine-id file do not. There is no file-locking anywhere; correctness depends on the assumption "only one writer at a time", which the settings UI breaks the moment two tabs are open. Finally, fail-closed discipline is missing in the read path: a GCM authentication failure on an encrypted blob silently falls through to any plaintext that happens to coexist, which would mask a real tamper attempt.

### Validators, injection surfaces

#### VALID-001 — `getEmailById` and callers never validate `folderHint`
- Location: `src/services/simple-imap-service.ts:827-857`, `src/tools/reading.ts:519-520, 722, 838, 849`, `src/tools/sending.ts:182, 231`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: `SimpleIMAPService.getEmailById(emailId, folderHint?)` does `this.validateEmailId(emailId)` but never validates `folderHint`, then passes it straight into `getMailboxLock(folder.path)`. The tool layer (`get_email_by_id`, `get_thread`, `extract_action_items`, `extract_meeting`, `reply_to_email`, `forward_email`) all cast `args.folder as string | undefined` and forward it without `validateTargetFolder`. A `folder` of `"INBOX\r\nLOGOUT"` or `"../Trash"` flows into the IMAP wire path and the cache key without filtering.
- Repro hypothesis: Call `get_email_by_id` with `{ emailId: "1", folder: "INBOX\r\nA001 LOGOUT" }` — imapflow will be asked to SELECT a folder containing CRLF.
- Suggested next step: Inside `getEmailById`, call `this.validateFolderName(folderHint)` when present, and add `validateTargetFolder(args.folder)` in every tool that accepts `folder` before passing it to the service.

> **Resolved in v3.0.44 — finish 3.0.41 sibling-path fix (#131).** `SimpleIMAPService.getEmailById` now calls `this.validateFolderName(folderHint)` when present (simple-imap-service.ts), and the by-id reading/sending tools (`get_email_by_id`, `get_thread`, `extract_action_items`, `extract_meeting`, `reply_to_email`, `forward_email`) route `args.folder` through the shared `optionalFolderHint()` gate instead of `as string | undefined` casts. Verified in v3.0.62.

#### VALID-002 — `search_emails` does not length-cap or sanitise `body`/`text`/`bcc`
- Location: `src/tools/reading.ts:546-598`, `src/services/simple-imap-service.ts:968-991`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: The MCP handler enforces `MAX_SEARCH_TEXT = 500` on `from`, `to`, `subject` but the `body`, `text`, and `bcc` filters are read with only `typeof === 'string'` and forwarded unbounded. The service layer's `sanitizeImapStr = s.replace(/["\\]/g, "")` strips quotes/backslashes but leaves CRLF, NUL, and the full C0 class intact.
- Repro hypothesis: Pass a 5 MB `body` filter — server burns memory; pass `body: "x\r\nA002 LOGOUT"` — sanitiser passes it untouched.
- Suggested next step: Length-cap `body`/`text`/`bcc` at `MAX_SEARCH_TEXT`; extend `sanitizeImapStr` to also strip `\r`, `\n`, `\x00`.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** The `search_emails` handler now length-caps `body`/`text`/`bcc` at `MAX_SEARCH_TEXT` (500), and `sanitizeImapStr` strips `\r`/`\n`/`\x00` in addition to quote/backslash.

#### VALID-003 — Two `validateFolderName` functions with the same name and contradictory rules
- Location: `src/services/simple-imap-service.ts:326-342` vs `src/utils/helpers.ts:205-216`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: The helpers' version is a "leaf only" check (rejects `/`, cap 255); the IMAP service's version is a "full-path" check (allows `/`, cap 1000). Code review at call sites must constantly disambiguate which one is in scope. This is exactly the shape of the v3.0.41 bug.
- Repro hypothesis: A future tool that imports `validateFolderName` from helpers but means to pass `Folders/Work` will throw `"folder contains invalid characters"`.
- Suggested next step: Rename the IMAP-private one to `validateImapPath` (or fold both into one well-named helper). Match length caps.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** Added a single shared full-path validator `validateImapPath()` in `helpers.ts`; the IMAP service's private `validateFolderName(name)` now delegates to it (rethrowing the message as an `Error`). The two no longer diverge. (The leaf-only `validateFolderName` in helpers stays distinct by design and is unchanged.)

#### VALID-005 — `saveDraft` has no attachment count or size cap (asymmetric to `sendEmail`)
- Location: `src/services/simple-imap-service.ts:1418-1442` vs `src/services/smtp-service.ts:378-415`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: SMTP send enforces `MAX_ATTACHMENTS = 20`, `MAX_ATTACHMENT_BYTES = 25 MB`. The IMAP `saveDraft` path mirrors the *sanitisation* but does **not** mirror the *size caps*. An agent can `save_draft` an unbounded Buffer/base64 payload, which nodemailer then buffers in RAM before `client.append`.
- Repro hypothesis: `save_draft` with a 500 MB base64 attachment — process OOMs.
- Suggested next step: Lift the per-file / total-size caps into a shared helper and apply in `saveDraft`.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** Shared caps (`MAX_ATTACHMENT_COUNT=20`, `MAX_ATTACHMENT_BYTES`/`MAX_TOTAL_ATTACHMENT_BYTES=25 MB`) and an `attachmentByteSize()` helper now live in `helpers.ts`; `saveDraft` enforces count + per-file + aggregate size before nodemailer buffers the payload, matching the SMTP send path.

#### VALID-006 — `validateAttachments` allows arbitrarily large `content` strings
- Location: `src/utils/helpers.ts:298-325`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: The helper guards `Array.isArray`, `length > 50`, `filename` is non-empty string, and `content` is string-or-Buffer. There is no length cap on the string/buffer itself.
- Suggested next step: Add a per-element byte cap inside `validateAttachments`.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** `validateAttachments` now computes decoded byte size per element (`attachmentByteSize`) and rejects when any single file exceeds `MAX_ATTACHMENT_BYTES` or the aggregate exceeds `MAX_TOTAL_ATTACHMENT_BYTES`. The count cap was also tightened from 50 to `MAX_ATTACHMENT_COUNT` (20) to match the send path.

#### VALID-009 — Tools that call `getEmailById(_, args.folder)` skip `validateTargetFolder`
- Location: `src/tools/reading.ts:516-520, 719-726, 835-849`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: Tool handlers in the same file split — list/search variants validate `folder` through `validateTargetFolder`, but the by-id family forwards it raw. Twin to VALID-001 at the call-site layer.
- Suggested next step: Add `validateTargetFolder(args.folder)` to every by-id handler.

> **Resolved in v3.0.44 — finish 3.0.41 sibling-path fix (#131).** Twin of VALID-001 at the call-site layer: the by-id reading tools forward `args.folder` through `optionalFolderHint()` (which runs `validateTargetFolder` and throws `McpError(InvalidParams)` on CRLF/`..`/control chars) rather than a raw cast. Verified in v3.0.62.

#### VALID-015 — Tools coerce `args.attachments` via `as EmailAttachment[]` after structural-only validator
- Location: `src/tools/sending.ts:159`, `src/tools/drafts.ts:312`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: `validateAttachments` rejects non-arrays and non-objects but doesn't strip extra keys. Casts then carry unknown attacker-controlled fields (`encoding`, `path`, `href`, `raw`) into nodemailer, which interprets some — e.g. `path` causes nodemailer to read a file from disk.
- Repro hypothesis: `send_email({ attachments: [{ filename: "x", content: "y", path: "/etc/passwd" }] })` — nodemailer would prefer `path` over `content`.
- Suggested next step: In `validateAttachments`, return a new sanitised array with only `{filename, content, contentType, contentId}`.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** Added `sanitizeAttachments()` in `helpers.ts` that rebuilds each element with only `{filename, content, contentType, size, contentId}`, dropping attacker-controlled keys (`path`/`href`/`raw`/`encoding`). `send_email`, `save_draft`, and `schedule_email` now use it instead of `args.attachments as EmailAttachment[]`.

#### VALID-004 — Helpers `validateLabelName` and `validateFolderName` are byte-identical duplicates
- Location: `src/utils/helpers.ts:185-196` and `205-216`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: The two functions differ only in the error message. When tightening rules later, only one will get patched and the sibling drifts.
- Suggested next step: Factor a `validateLeafName(value, fieldName)` and have both call it.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** Added the shared `validateLeafName(value, fieldName)`; `validateLabelName`/`validateFolderName` now both delegate to it.

#### VALID-007 — `validateLabelName`/`validateFolderName` miss DEL (\x7f) and C1 control range
- Location: `src/utils/helpers.ts:189, 209`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: Regex is `/[\x00-\x1f]/` — `\x7f` (DEL) and `\x80-\x9f` (C1) pass through. `sanitizeText` in `src/settings/security.ts:369` already documents the full C0/C1+DEL range as risky.
- Suggested next step: Standardise on the security.ts `CONTROL_CHARS_RE` for every text-shape validator.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** A shared `CONTROL_CHARS_RE = /[\x00-\x1f\x7f\x80-\x9f]/` now backs `validateLeafName`, `validateTargetFolder`, and `validateImapPath` — DEL and the C1 range are rejected everywhere.

#### VALID-008 — `requireNumericEmailId` accepts unbounded-length numeric strings and leading zeros
- Location: `src/utils/helpers.ts:275-280`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `^\d+$` admits thousand-digit strings and `"0000001"`. IMAP UIDs are 32-bit unsigned; large strings burn log space and Bridge time.
- Suggested next step: Cap to e.g. 12 digits and disallow leading zero except `"0"`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `requireNumericEmailId` now matches `/^(0|[1-9]\d{0,9})$/` — leading zeros rejected (except `"0"`), capped at 10 digits (32-bit UID range).

#### VALID-010 — `parseEmails` has no per-token length cap, exposes regex to large inputs
- Location: `src/utils/helpers.ts:43-63`
- Category: Performance
- Severity: Low
- Confidence: Suspect
- Description: `parseEmails` splits on commas without a per-token byte cap. The regex `^[^<>]*<([^<>]+)>$` runs on the whole token first.
- Suggested next step: Reject any single token whose `trimmed.length > MAX_ADDRESS_TOKEN` (e.g. 1024).

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `parseEmails` drops (with a warning) any token longer than the exported `MAX_ADDRESS_TOKEN` (1024) before the regex runs.

#### VALID-011 — Empty/whitespace `folderName` passes `validateTargetFolder`; redundant follow-up check inconsistent
- Location: `src/utils/helpers.ts:227-241`, `src/tools/folders.ts:122-160`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `validateTargetFolder("")` returns `null` but `create_folder`/`delete_folder`/`rename_folder` always need a real name and each handler repeats `!args.folderName || ... trim()`.
- Suggested next step: Add a `validateRequiredTargetFolder` wrapper used by folder-mutating tools.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** Added `validateRequiredTargetFolder`; `create_folder`/`delete_folder`/`rename_folder` now use it, replacing their duplicated `!folderName || ...trim()` guards with one `McpError(InvalidParams)` shape.

#### VALID-012 — IMAP `validateFolderName` (private) doesn't reject leading/trailing whitespace or `&` escape
- Location: `src/services/simple-imap-service.ts:326-342`
- Category: Correctness
- Severity: Low
- Confidence: Suspect
- Description: IMAP folder names use UTF-7 modified, where `&` is the escape character. The validator strips no whitespace and never warns about `&`. Combined with leading-whitespace, allows silently-different paths to be opened.
- Suggested next step: Either `trim()` and re-validate, or explicitly reject leading/trailing whitespace.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `validateImapPath` (which the private `validateFolderName` delegates to since VALID-003) now rejects leading/trailing whitespace. The UTF-7 `&` escape is intentionally left intact — legitimate encoded folder names contain it.

#### VALID-013 — `saveDraft` `inReplyTo` strips only `[\r\n\x00]` while `references` strips full `[\x00-\x1f\x7f]`
- Location: `src/services/simple-imap-service.ts:1414-1415`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Sibling fields in the same `mailOptions` literal use different sanitisation masks.
- Suggested next step: Use `stripHeaderInjection` for both fields.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `saveDraft` now sanitises both `inReplyTo` and `references` with one shared `stripHeaderField` (the broader `[\x00-\x1f\x7f]` mask), so the sibling fields no longer diverge.

#### VALID-014 — `validateTargetFolder` length cap of 1000 lets through paths longer than the leaf cap of 255
- Location: `src/utils/helpers.ts:237` vs `192/212`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `validateTargetFolder` caps at 1000 — but a `Folders/<name>` constructed by a tool may originate from `validateFolderName` (cap 255 for the leaf). The three layers do not enforce a single bound.
- Suggested next step: Pick one path-length cap and enforce it at the outer-most validator.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** The 1000-char cap in `validateTargetFolder`/`validateImapPath` is now documented as the single full-path bound; the per-leaf validators enforce 255 on each segment a caller composes. A multi-segment path legitimately exceeds any single 255-char leaf, so the outer cap stays 1000.

#### VALID-016 — `fts_search` accepts `folder` without any validation
- Location: `src/tools/reading.ts:780-800`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `folder: typeof args.folder === "string" ? args.folder : undefined` is passed straight into `fts.search`. The inconsistency with `search_emails` means `fts_search` admits values its IMAP twin would reject.
- Suggested next step: Run `validateTargetFolder` on the folder filter for parity.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** The `fts_search` handler now runs `validateTargetFolder` on the `folder` filter and throws `McpError(InvalidParams)` on failure, matching `search_emails`.

#### VALID-017 — `sourceFolder` validator allows empty string by returning `null` (silent fall-back)
- Location: `src/utils/helpers.ts:228-230`
- Category: UX
- Severity: Low
- Confidence: Confirmed
- Description: `validateTargetFolder("")` returns `null`. The `optionalSourceFolder` wrappers re-check empty-string. The contract is fragile — the next caller that simply does `validateTargetFolder(raw)` will hand `""` to imapflow.
- Suggested next step: Either make `validateTargetFolder("")` an error, or return a normalised `string | undefined`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** The empty-as-default contract is now documented in-code, and the fragile follow-up is closed at the dangerous call sites: folder-mutating tools use the new `validateRequiredTargetFolder` (VALID-011) and the shared `optionalSourceFolder` (VALID-021) re-checks empty-string. `validateTargetFolder("") → null` is kept deliberately for the search/list tools that fall back to INBOX.

#### VALID-018 — `validateAttachments` filename allows any character including path separators
- Location: `src/utils/helpers.ts:314-316`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: Filename is only checked as `non-empty string`. CRLF and path traversal are scrubbed inside the SMTP/IMAP layers, but the validator itself permits `"../../../etc/passwd"`.
- Suggested next step: In `validateAttachments`, reject path separators and control chars, and cap to 255.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `validateAttachments` now rejects `/`, `\`, `..`, control chars, and filenames over 255 chars per element.

#### VALID-019 — `validateLabelName` rejects `/` but allows non-printable / non-ASCII that imapflow UTF-7 encodes
- Location: `src/utils/helpers.ts:185-196`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: A label like `"Wörk"` becomes `Labels/Wörk`, which imapflow encodes to `Labels/W&APY-rk`. Agents using the label as an in-memory map key will mismatch the IMAP-stored name on the next listing.
- Suggested next step: Document the UTF-7 expansion factor or measure encoded length when capping.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** Documented the modified-UTF-7 expansion in the `validateLabelName` doc-comment (a non-ASCII leaf is accepted but encoded on the wire, so callers must re-list to learn the stored name). Measuring encoded length when capping is intentionally not done — the 255-char leaf cap is already conservative relative to IMAP limits.

#### VALID-021 — `optionalSourceFolder` exists in two files and is duplicated
- Location: `src/tools/actions.ts:301-309` and `src/tools/deletion.ts:22-30`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Identical helper, two definitions.
- Suggested next step: Move to `src/utils/helpers.ts` and export.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `optionalSourceFolder` now lives in `src/utils/helpers.ts` and is imported by both `tools/actions.ts` and `tools/deletion.ts`; the two local copies were removed.

#### VALID-020 — `requireNumericEmailId` and IMAP-private `validateEmailId` produce different error shapes
- Location: `src/utils/helpers.ts:275-280` vs `src/services/simple-imap-service.ts:285-289`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: Two validators with the same intent — one throws `McpError(InvalidParams, ...)`, the other throws plain `Error`. The MCP layer turns the second into a 500-ish internal error rather than an actionable client error.
- Suggested next step: Drop `simple-imap-service.validateEmailId` and require all entrypoints to pre-validate.

> **Acknowledged in v3.0.64 — by design: defense-in-depth last line.** The IMAP-private `validateEmailId` is an internal last-line guard reached only after the MCP-layer `requireNumericEmailId` has run; removing it to unify the error shape trades a regression risk for a cosmetic error-code difference on an unreachable path.

**VALIDATORS-INJECTION patterns:** the validator surface is essentially three sibling layers — `helpers.ts` (per-tool gate), tool-handler `optionalX` wrappers (per-call disambiguation), and the IMAP service's private re-validators (last-line). Each layer drifts independently: different character classes, different length caps, different empty-string semantics, and same-named functions (`validateFolderName`) that mean different things. The recurring failure mode is "next caller picks the wrong sibling": v3.0.41's `optionalSourceFolder` fix was exactly this and several more wait in `getEmailById`'s `folderHint`, the `body`/`text`/`bcc` search filters, and the `saveDraft` size-cap asymmetry.

### Data parsers — FTS, analytics, content-parser, attachments

#### PARSE-002 — FTS index has no folder-allowlist enforcement; snippets can leak from sensitive folders
- Location: `src/services/fts-service.ts:138-156`, `src/tools/reading.ts:780-801`
- Category: Security / Data-loss
- Severity: High
- Confidence: Likely
- Description: `searchAll` runs MATCH across every indexed message regardless of which folder the caller has scoped to. `fts_search` accepts an optional `folder` argument; when the caller omits it, the response includes hits and `snippet(...)` text from every folder in the DB — including Trash, Spam, Archive, and any folder a downstream MCP grant might forbid. The grant system gates the *tool*, not the *snippet content*.
- Repro hypothesis: Grant a sub-agent only `fts_search` + INBOX read. Run `fts_search query:"password"`; snippets returned will include hits in `Trash`/`Sent` decrypted bodies.
- Suggested next step: Either require `folder` to be non-empty, or have `fts_search` consult the agent's folder allowlist and inject a `folder IN (...)` filter into the prepared statement.

> **Resolved in v3.0.48 — Batch 8.** `FtsIndexService.search()` now accepts an optional `allowedFolders` parameter (bound `folder IN (?, ?, …)`, no string interpolation); the `fts_search` tool resolves the caller's grant via a new `GrantManager.resolveAllowedFolders(clientId)` accessor and passes the list. Trusted/stdio callers and unrestricted grants pass `undefined` (unchanged behavior); restricted grants scope snippet content to the allowlist; explicit `[]` short-circuits to zero hits.

#### PARSE-001 — `fts_search` propagates raw user query to FTS5 MATCH; malformed syntax raises uncaught exception
- Location: `src/services/fts-service.ts:191-213`, `src/tools/reading.ts:780-801`
- Category: Correctness / UX
- Severity: Medium
- Confidence: Confirmed
- Description: `search()` passes `opts.query` straight into `MATCH ?` with no escaping and no try/catch. better-sqlite3 throws `SqliteError: fts5: syntax error near "..."` on any FTS5 syntax violation. SQL injection proper is blocked (parameter is bound), but FTS5 syntax is its own DSL.
- Repro hypothesis: Call `fts_search` with `query: '"hello'` → unhandled SqliteError surfaces to MCP client.
- Suggested next step: Wrap `searchAll.all/searchFolder.all` in try/catch returning `{ hits: [], queryError: msg }`, or sanitize via a "double-quote if not a valid expression" fallback.
> **Resolved in v3.0.54 — parser/analytics hardening.** Added a `runMatch()` wrapper in `fts-service.ts` around all three `.all()` call sites; it catches FTS5 query-DSL syntax errors and returns a clean empty result, re-throwing anything else. Fix is service-side only (the tool handler still receives an array).

#### PARSE-003 — `fts_rebuild` clears the index before checking it can repopulate
- Location: `src/tools/reading.ts:803-819`, `src/services/fts-service.ts:175-184`
- Category: Data-loss
- Severity: Medium
- Confidence: Confirmed
- Description: `fts.clear()` (line 812) is executed via `db.exec`, not part of the upsertMany transaction. If the map throws or better-sqlite3 raises mid-transaction, the clear has already committed. User sees an empty index until they discover and re-trigger rebuild.
- Repro hypothesis: Inject a malformed `EmailMessage` with `body` of type `Buffer` instead of string → `stripHtml` throws on `.replace`, leaving the index wiped.
- Suggested next step: Wrap clear+upsertMany in a single `db.transaction`.
> **Resolved in v3.0.54 — parser/analytics hardening.** Added `FtsIndexService.rebuild(records)` that runs the `DELETE` + bulk upsert inside one `db.transaction`, rolling back to the prior index on any throw. The `fts_rebuild` tool handler in `src/tools/reading.ts` (not in this batch's ownership) should be switched from `clear()`+`upsertMany()` to `rebuild()` — flagged to the tools batch.
> **Wiring completed in v3.0.58 — validator/draft/parser hardening.** The `fts_rebuild` tool handler in `src/tools/reading.ts` now calls `fts.rebuild([...])` instead of the non-atomic `fts.clear()` + `fts.upsertMany(...)` pair, so a throw mid-rebuild no longer leaves the index wiped.

#### PARSE-004 — `processContacts()` silently drops every new contact past the 10 000-cap including the largest senders
- Location: `src/services/analytics-service.ts:124-146, 148-164`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: The cap is enforced in INSERTION ORDER. The inbox is iterated first, then sent. If inbox contains 10 000 unique senders (newsletters, automated bounces), every `to` address in `sentEmails` is silently dropped — even the user's own most-frequent recipients.
- Repro hypothesis: Load 10 001 inbox emails from distinct senders; then load 100 sent emails to high-value contacts. `getContacts(sortBy: 'sent')` returns an empty result.
- Suggested next step: Either raise the cap, evict the lowest-interaction contact on insert pressure, or process sent first / interleave folders.
> **Resolved in v3.0.54 — parser/analytics hardening.** `processContacts()` now processes sent recipients before inbox senders, so the user's own most-frequent contacts claim their map slots before a flood of one-off inbox senders can exhaust the cap.

#### PARSE-005 — `calculateVolumeTrends` buckets by UTC ISO date; user-facing days drift by up to 24h
- Location: `src/services/analytics-service.ts:352-381`
- Category: Correctness / UX
- Severity: Medium
- Confidence: Confirmed
- Description: Both the bucket keys and the per-email key use `toISOString().split('T')[0]` — i.e., UTC date. An email received at 9 PM ET on Monday gets bucketed as Tuesday UTC. The "Mon" bar in volume-trends UI under-counts evening mail by up to ~5 hours of traffic per day.
- Repro hypothesis: Send a test email at 22:00 ET; observe it land in the next-day bucket.
- Suggested next step: Accept a `timezone` option (default to host TZ), or compute date strings in local time.
> **Resolved in v3.0.54 — parser/analytics hardening.** Volume-trend day buckets now use host-local calendar dates via a `localDateKey()` helper, the SAME basis as `peakActivityHours` (host-local `getHours()`). Buckets are seeded by walking back N calendar days with local y/m/d construction so DST can't collapse/skip a day. No TZ library introduced. See PARSE-006 for the unified-basis decision.

#### PARSE-010 — iCal `DTSTART;TZID=...:` parameter dropped on the floor
- Location: `src/services/content-parser.ts:174-182, 252-256`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: `splitProperty` discards everything between the property name and the colon. For `DTSTART;TZID=America/New_York:20260601T140000`, only `20260601T140000` reaches the `Meeting` object as `start`. The parsed `start` is a naïve "2026-06-01 14:00" with no zone.
- Repro hypothesis: Parse a calendar invite with `DTSTART;TZID=America/Los_Angeles:20260601T090000` — `extract_meeting` returns `start: "20260601T090000"`.
- Suggested next step: Have `splitProperty` also return the parameters dict, persist `start_tzid` on the Meeting.
> **Resolved in v3.0.54 — parser/analytics hardening.** `splitProperty` now returns a `params` map (keys upper-cased, values case-preserved for zone names); `parseIcs` persists `DTSTART`'s `TZID` on a new optional `Meeting.startTzid`. The `start` value remains the raw RFC 5545 date-time string.

#### PARSE-014 — `downloadAttachment` re-fetches the full RFC 2822 source then base64-encodes the whole attachment in memory
- Location: `src/services/simple-imap-service.ts:1222-1280`, `1287-1334`
- Category: Performance / Data-loss
- Severity: Medium
- Confidence: Confirmed
- Description: For a 25 MB PDF attachment, ~25 MB raw + ~33 MB base64 + parser overhead held simultaneously. There's no upfront size check.
- Repro hypothesis: Stage a 50 MB PDF attachment; `download_attachment` either OOMs or returns a 67 MB base64 string.
- Suggested next step: Reject early when `att.size > LIMIT`; or stream the IMAP body part directly to a base64 transform.
> **Resolved in v3.0.52 — IMAP service hardening (bounded-cap fix).** `downloadAttachment` now rejects with a clear error when `att.size > MAX_ATTACHMENT_DOWNLOAD_BYTES` (25 MB, matching Proton's own limit) *before* the full-source re-fetch + base64 encode. True streaming of the body part is a larger refactor left as a documented follow-up; the cap is the proportionate guard against OOM.

#### PARSE-016 — `responseTimeStats` Message-ID lookup never normalizes angle brackets
- Location: `src/services/analytics-service.ts:309-332`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: If `email.headers['message-id']` is stored as `<abc@x.com>` (mailparser default) but `sent.inReplyTo` is normalized to `abc@x.com` (or vice-versa), the lookup misses every reply → `responseTimeStats` is always `null` even when there's clearly data.
- Suggested next step: Strip `<>` on both sides before keying.
> **Resolved in v3.0.54 — parser/analytics hardening.** Added `normalizeMessageId()` (trim + strip surrounding `<>`); both the stored Message-ID and `inReplyTo` are keyed through it, so `<abc@x.com>` matches `abc@x.com`.

#### PARSE-006 — `calculatePeakActivityHours` uses local-host `getHours()` while volume trends use UTC
- Location: `src/services/analytics-service.ts:383-396`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: Within the same `EmailAnalytics` object, `volumeTrends` is bucketed in UTC but `peakActivityHours` uses `email.date.getHours()` which returns the host's local hour. Two charts derived from the same dataset disagree.
- Suggested next step: Pick one timezone discipline; document it.
> **Resolved in v3.0.54 — parser/analytics hardening.** TIME-BASIS DECISION: host-local time for BOTH charts. Peak-activity-hours is inherently a human-experience metric ("when in my day do I get mail") and only reads correctly in local time; `volumeTrends` was changed to match (PARSE-005). The shared basis is documented in a TIME BASIS comment on `calculateVolumeTrends` and a one-liner on `calculatePeakActivityHours`. No TZ library.

#### PARSE-007 — `responseTimeStats.median` picks the upper-middle element for even-length arrays (off-by-one)
- Location: `src/services/analytics-service.ts:336-346`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `const median = responseTimes[Math.floor(responseTimes.length / 2)]`. For an even-length sorted array `[1, 2, 3, 4]`, returns `3`, not the conventional `(2+3)/2 = 2.5`.
- Suggested next step: `n % 2 ? rt[(n-1)/2] : (rt[n/2 - 1] + rt[n/2]) / 2`.
> **Resolved in v3.0.54 — parser/analytics hardening.** Applied the suggested even/odd median; `[1,2,3,4]` now yields `2.5`.

#### PARSE-008 — `stripHtml` unescapes entities AFTER tag-strip
- Location: `src/services/simple-imap-service.ts:61-75`
- Category: Security
- Severity: Low
- Confidence: Likely
- Description: An attacker who supplies HTML like `&lt;script&gt;alert(1)&lt;/script&gt;` (encoded text content) emerges as literal `<script>alert(1)</script>` in the FTS body and the bodyPreview. If any downstream consumer treats those fields as HTML, you have stored XSS.
- Repro hypothesis: Inject an email whose plaintext body literally contains `&lt;script&gt;evil()&lt;/script&gt;`; observe FTS body now contains raw `<script>...</script>`.
- Suggested next step: Decode entities FIRST, then strip tags (the standard order); handle numeric entities for parity.
> **Deferred — owned by the IMAP batch (v3.0.54 parser batch).** `stripHtml` lives in `src/services/simple-imap-service.ts`, which the parser/analytics batch must not touch. Flagged to the IMAP batch for the entity-decode-order fix.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** `stripHtml` now decodes entities (named + numeric decimal `&#NN;` and hex `&#xNN;`) BEFORE stripping tags, so encoded markup like `&lt;script&gt;…&lt;/script&gt;` decodes to real tags that the subsequent tag-strip removes, rather than surfacing as literal `<script>` in the FTS body / bodyPreview. `&amp;` is decoded last so double-encoded sequences are not collapsed into live markup.

#### PARSE-009 — `stripHtml` ignores HTML comments
- Location: `src/services/simple-imap-service.ts:61-75`
- Category: Correctness / Security
- Severity: Low
- Confidence: Confirmed
- Description: HTML comments are `<!-- ... -->`; the regex consumes `<!--` and `-->`, but leaves any HTML inside the comment visible as tag-stripped prose. `<!-- secret: pw123 -->` becomes `  -- secret: pw123 --` in the FTS body.
- Suggested next step: Add `.replace(/<!--[\s\S]*?-->/g, ' ')` as the first pass.
> **Deferred — owned by the IMAP batch (v3.0.54 parser batch).** `stripHtml` lives in `src/services/simple-imap-service.ts`, outside this batch's ownership. Flagged to the IMAP batch for the comment-stripping pass.
> **Resolved in v3.0.58 — validator/draft/parser hardening.** `stripHtml` now drops HTML comments (`/<!--[\s\S]*?-->/g`) as the first pass, so the contents of `<!-- secret: pw123 -->` no longer survive as tag-stripped prose in the FTS body.

#### PARSE-011 — iCal parser ignores `BEGIN:VEVENT` lines with parameters
- Location: `src/services/content-parser.ts:220-232`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: The block-start check is strict equality `upper === "BEGIN:VEVENT"`. Some legacy producers emit `BEGIN:VEVENT;X-MICROSOFT-CDO-BUSYSTATUS=BUSY` — parser silently produces `null`.
- Suggested next step: Compare on `upper.startsWith("BEGIN:VEVENT")` and use the parameter-aware `splitProperty`.
> **Resolved in v3.0.54 — parser/analytics hardening.** Block boundaries are now matched on the parsed property name/value via `splitProperty` (`BEGIN` name + `VEVENT` value), and `END:VEVENT;…` is tolerated, so a `BEGIN:VEVENT;X-…=BUSY` line is recognized.

#### PARSE-012 — `unfoldLines` first-line leading-whitespace handling
- Location: `src/services/content-parser.ts:154-168`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: When the *first* line of input starts with a space, the `if (folded.length > 0)` branch is skipped and the line is pushed verbatim including the leading space, then later `splitProperty` will treat ` SUMMARY:foo` as having name ` SUMMARY` (uppercased to ` SUMMARY`, not `SUMMARY`), so SUMMARY is dropped.
- Suggested next step: Trim leading whitespace on the first line.
> **Resolved in v3.0.54 — parser/analytics hardening.** `unfoldLines` now strips leading space/tab on the first line (a leading-whitespace first line is not a fold continuation — nothing to fold onto), so `splitProperty` reads `SUMMARY`, not `" SUMMARY"`.

#### PARSE-013 — `MAX_BODY_BYTES` cap uses `body.slice(0, MAX_BODY_BYTES)` measured in chars after byte-length check
- Location: `src/services/content-parser.ts:80-90`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: The guard says "Byte length on UTF-8" but then truncates with `.slice(0, MAX_BODY_BYTES)` which is a *character* count. For a body of multibyte chars, the slice keeps up to ~400 KB of actual bytes.
- Suggested next step: Slice on a Buffer and decode back, or use `Buffer.byteLength` to walk to the right char-index.
> **Resolved in v3.0.54 — parser/analytics hardening.** Truncation now slices a UTF-8 `Buffer` (`Buffer.from(body).subarray(0, MAX_BODY_BYTES).toString("utf-8")`) so the cap is honest in bytes for multibyte bodies.

#### PARSE-015 — `extractAttachmentMeta`'s `att.size` is `number | undefined` but added unguarded
- Location: `src/services/analytics-service.ts:235-243, 398-412`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `totalBytes += att.size;` — if `att.size` is ever `undefined`, the addition yields `NaN` and propagates through `bytesToMB(NaN)`, surfacing as `null`/`NaN` in `EmailStats.storageUsedMB`.
- Suggested next step: `totalBytes += att.size ?? 0;`.
> **Resolved in v3.0.54 — parser/analytics hardening.** Applied `att.size ?? 0` at both addition sites (`getEmailStats`, `calculateAttachmentStats`). Note: the `EmailAttachment.size` type is declared `number` (non-optional), but mailparser can produce attachments without a size at runtime, so the guard is defensive against that type-contract violation.

#### PARSE-017 — `inferOrganization` `'gov'` listed in both TLD and compound-SLD lists
- Location: `src/services/analytics-service.ts:38-61`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `cdc.gov` returns "Cdc" without proper capitalization; `gov.uk` (root-only) returns "Gov".
- Suggested next step: Guard the compound branch with `parts.length >= 3` strictly; uppercase known acronym TLDs (edu/gov/mil).
> **Resolved in v3.0.54 — parser/analytics hardening.** Acronym TLDs (`edu`/`gov`/`mil`) now upper-case the whole label (`cdc.gov → CDC`); `gov` in the compound-SLD branch only fires for real compounds (`gov.uk → Hmrc`-style, requires `parts.length >= 3`), with the root-TLD case handled by the TLD branch above.

#### PARSE-018 — `extractActionItems` dedup key punctuation-sensitive
- Location: `src/services/content-parser.ts:122-124`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: `const key = text.toLowerCase().replace(/\s+/g, " ").trim();` — case- and whitespace-tolerant but punctuation-sensitive. `- Fix bug.\n- Fix bug!` produces two action items.
- Suggested next step: Also strip trailing `[.,;:!?]+` on the dedup key.
> **Resolved in v3.0.54 — parser/analytics hardening.** The dedup key now also strips a trailing `[.,;:!?]+` run, so `Fix bug.` / `Fix bug!` / `Fix bug` collapse to one item.

#### PARSE-019 — `BULLET_RE` doesn't recognize `→`, `▪`, `‣`, or em-dash bullets
- Location: `src/services/content-parser.ts:57`
- Category: Tech-debt
- Severity: Low
- Confidence: Likely
- Description: `BULLET_RE = /^\s*(?:[-*•]|\d+[.)]|\[[ xX]\])\s+/`. Mac-formatted plaintext often uses `‣`, `▪`, em-dash `—`. Lines starting with those fall through to the "no bullet" branch.
- Suggested next step: Add `‣▪◦◾○▶→—` to the character class.
> **Resolved in v3.0.54 — parser/analytics hardening.** `BULLET_RE` now includes `‣ ▪ ◦ ◾ ○ ▶ →` and the em-dash `—` in its leading-marker character class.

#### PARSE-020 — `extractEmailAddress` regex `/<([^>]+)>/` accepts the first angle-bracket pair anywhere
- Location: `src/utils/helpers.ts:124-127`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: For a malformed value like `<bogus> "Alice" <real@x.com>`, this returns `bogus`. A hostile From header poisons the contact map.
- Suggested next step: Anchor as `/^[^<>]*<([^<>]+)>\s*$/`, or split-on-last-`<`.

> **Resolved in v3.0.62 — tools/validators/parsers/credentials low-severity sweep.** `extractEmailAddress` now takes the LAST angle-bracket pair (split-on-last-`<`), so `<bogus> "Alice" <real@x.com>` returns `real@x.com` instead of `bogus`.

**DATA-PARSERS patterns:** three recurring shapes. (1) *Defensive caps that don't quite match their intent* — byte-length checks with char-length truncation, contact caps that drop the highest-value entries because of iteration order, FTS limits without query-DSL escaping. (2) *Time-zone schizophrenia* — analytics mixes UTC bucketing for `volumeTrends` with host-local `getHours()` for `peakActivityHours`, and the iCal parser silently discards `TZID=`. (3) *HTML/MIME shaping is one-pass, no-encoding-aware* — `stripHtml` decodes entities after tag-strip (single-pass bypass), doesn't strip HTML comments. Highest-priority: PARSE-002 (folder-scoped data leak via FTS), PARSE-014 (attachment OOM), PARSE-005/006 (timezone correctness), PARSE-010 (iCal TZID drop).

### Settings UI, notifications, native tray

#### UI-001 — `<form onsubmit="return false">` inline handler is blocked by nonce'd script-src
- Location: `src/settings/tabs/setup.ts:28`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: The Setup tab HTML still contains a raw inline event handler `<form id="setup-form" onsubmit="return false">`. The server's CSP sets `script-src 'nonce-…' 'unsafe-inline'`, and CSP3 explicitly ignores `'unsafe-inline'` when any nonce is present. Browsers refuse to run the inline `onsubmit` attribute.
- Repro hypothesis: Load Setup tab in Chrome 120+, open DevTools → Console; pressing Enter inside any input triggers a CSP "Refused to execute inline event handler" violation report.
- Suggested next step: Replace with `<form id="setup-form" data-submit="noop">` and add `document.addEventListener('submit', e => e.preventDefault())`.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Setup form now uses `data-submit="noop"`; a delegated `submit` listener in the nonce'd script block calls `preventDefault()`. No inline handlers remain in the UI.

#### UI-002 — Main settings CSP keeps `'unsafe-inline'` in `script-src` alongside the nonce
- Location: `src/settings/server.ts:602`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: The header is `script-src 'nonce-${cspNonce}' 'unsafe-inline'`. CSP3-compliant browsers ignore `'unsafe-inline'` once any nonce appears — but CSP Level 1/2 browsers do NOT. Safari 13.x evaluates the keyword.
- Suggested next step: Drop the `'unsafe-inline'` keyword now that inline handlers are eliminated.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Main UI CSP is now `script-src 'nonce-${cspNonce}'` with no `'unsafe-inline'`. Regression test in `src/settings/server.http.test.ts`.

#### UI-003 — `/agent-setup` HTML serves `script-src 'unsafe-inline'; style-src 'unsafe-inline'` with no nonce
- Location: `src/settings/server.ts:661`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: The agent-setup page sets `Content-Security-Policy: default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'`. Several interpolations (`${data.version}`, `${data.summary}`, etc.) are inserted raw without `escapeHtml`. A future "let the operator brand this page" change introduces stored XSS.
- Suggested next step: (a) wrap every `${data.*}` in `escapeHtml`; (b) remove `script-src 'unsafe-inline'` entirely.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Every remaining raw `${data.*}` interpolation is now `escapeHtml`'d, `escapeHtml` also escapes `'`, and the page CSP is `script-src 'self'; style-src 'self' 'unsafe-inline'` (no inline scripts on the page). Regression test in `src/settings/server.http.test.ts`.

#### UI-005 — Settings UI bind retries 5×1 s and only warns; no fallback port, no signal to caller
- Location: `src/index.ts:1611-1647`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: If a non-mailpouch listener holds port 8765, the MCP gives up after 5 retries, logs warn, and continues without the UI. The tray will still build an "Open Settings" item; clicking does nothing because `_settingsUrl` is empty.
- Repro hypothesis: Run `python3 -m http.server 8765` in one terminal, then start the MCP. Watchdog logs "could not bind…", tray "Open Settings" item still appears but clicking fails.
- Suggested next step: Gate the tray "Open Settings" item visibility on `_settingsEnabled && _settingsUrl`; offer to try `port+1`.
> **Resolved in v3.0.55 — persistence atomicity (tray-gating half; verified already in place).** `_buildTrayItems()` in `index.ts` only emits the "Open Settings" entry when `_settingsEnabled && _settingsUrl`, so a failed bind no longer shows a dead menu item. The `port+1` fallback suggestion remains an open enhancement out of scope for this batch.

#### UI-006 — `/api/shutdown` calls `process.exit(0)` without destroying tray subprocess
- Location: `src/settings/server.ts:1777-1782`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: The shutdown endpoint replies 200 then calls `setTimeout(() => process.exit(0), 300)`. `process.exit` bypasses the `case "quit"` cleanup that calls `_activeTray?.destroy()`. On systray2 fallback path, the tray helper binary keeps running.
- Repro hypothesis: On Linux without `@mailpouch/tray-native-linux-x64-gnu`, click in-UI "Shutdown" then `pgrep -af tray` — the helper keeps running.
- Suggested next step: Route `/api/shutdown` through a callback that the caller registers.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Added `onShutdownRequested` to `ServerSecurityOptions`; `index.ts` wires it to `gracefulShutdown` (which calls `_trayInstance.destroy()` before exit). The endpoint falls back to `process.exit(0)` only when no callback is registered. Regression test asserts the callback fires and `process.exit` is not called.

#### UI-007 — POST `/api/config` has a read-modify-write race with the MCP-side config writer
- Location: `src/settings/server.ts:686-874`
- Category: Concurrency
- Severity: Medium
- Confidence: Likely
- Description: The handler does `loadConfig()` → merges body → `saveConfigWithCredentials(current)`. No file lock. The MCP runtime polls/persists the same config every 15 s. Last-writer-wins.
- Suggested next step: Introduce a single-writer queue (or `O_EXCL` lock file) around config mutations.
> **Resolved in v3.0.55 — config read-modify-write serialized by the CRED-008 file lock (#167).** Verified: `saveConfig` and `writeRegistry` in `src/config/loader.ts` now run their full read-modify-write under `withConfigLock`/`withConfigWriteLockAsync` — an `O_EXCL` `${config}.lock` with stale reclamation + reentrancy, plus an in-process async mutex — so the settings-server POST `/api/config` write and the MCP-side periodic writer can no longer clobber each other.

#### UI-009 — POST `/api/write-claude-desktop` overwrites a non-JSON config silently
- Location: `src/settings/server.ts:1652-1676`
- Category: Data-loss
- Severity: Medium
- Confidence: Confirmed
- Description: The handler reads the user's `claude_desktop_config.json`, then `JSON.parse`s; if either step throws, the `catch` branch resets `existing = {}` *and proceeds to writeFileSync*. Any prior content the parser couldn't handle is wiped without backup.
- Repro hypothesis: Put `// comment\n{}` in `claude_desktop_config.json`, run the wizard's "Write to Claude Desktop" step. File ends up as `{"mcpServers":{"mailpouch":…}}`, comment gone.
- Suggested next step: On parse failure, bail with `{ ok: false, error: "Existing config could not be parsed…" }`.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Reads now distinguish ENOENT (safe to start fresh) from any other read error (bail). A present-but-unparseable file returns `{ ok: false }` and is left untouched. Regression test in `src/settings/server.claude-desktop.mocked.test.ts`.

#### UI-011 — Agent `approve` endpoint passes `conditions`/`toolOverrides` through without shape validation
- Location: `src/settings/server.ts:1446-1471`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: The handler validates `body.preset` but forwards `body.toolOverrides` and `body.conditions` as untyped record-of-unknown.
- Repro hypothesis: `curl -X POST /api/agents/<id>/approve -d '{"preset":"read_only","conditions":{"__proto__":{"isAdmin":true}}}'`.
- Suggested next step: Whitelist condition keys and tool-override keys.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** `toolOverrides` is filtered to known `ALL_TOOLS` keys with boolean values; `conditions` is rebuilt key-by-key from the `GrantConditions` whitelist with per-value type checks. Unknown and `__proto__`-style keys are dropped. Regression test verifies no prototype pollution and that only sanitized values are stored.

#### UI-004 — Local `esc` helpers omit `>` and `'`, splitting the codebase's escaping contract
- Location: `src/settings/shell.ts:372, 573, 615`
- Category: Security
- Severity: Low
- Confidence: Likely
- Description: Three places define an inline `esc = s => …` that only replaces `&`, `<`, and `"` — missing both `>` and `'`. The module-level `escHtml` covers all five.
- Suggested next step: Delete the local `esc` re-definitions and use the module-level `escHtml`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Already closed in v3.0.57 (#168): the three local `esc` helpers in `shell.ts` (lines 377/578/620) are now full 5-replacement functions (`&`, `<`, `>`, `"`, `'`), identical to the module-level `escHtml` at line 2092. The 3-replacement form the finding describes no longer exists.

#### UI-008 — Desktop notifier subprocess has no timeout
- Location: `src/notifications/desktop.ts:78-88`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `defaultRunner` spawns `osascript`/`notify-send`/`powershell.exe` with `stdio: "ignore"` and resolves on `close`/`error`. There's no `setTimeout(() => child.kill(), …)`. PowerShell-based Windows toasts can stall; `notify-send` against a missing DBus session does the same.
- Suggested next step: Wrap with a `setTimeout(() => child.kill('SIGKILL'), 3000)`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `defaultRunner` now arms a 3 s `setTimeout` that `child.kill("SIGKILL")`s the notifier subprocess and resolves `{ code: -1 }`; the timer is cleared on a normal close/error and is `.unref()`'d.

#### UI-010 — `process.env.APPDATA ?? ""` falls through to relative paths on Windows when env is absent
- Location: `src/settings/server.ts:1645, 1624`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: If `APPDATA` is unset (Windows service without user profile loaded), the result is the relative path `Claude\claude_desktop_config.json` interpreted against cwd.
- Suggested next step: Apply the guard used at 1710-1719 to both sites.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Both sites now resolve the Claude Desktop config path through a shared `claudeDesktopConfigPath()` helper that returns `null` on Windows when `%APPDATA%` is unset; the status route reports `found:false, configPath:null` and the write route refuses with an actionable error instead of building a CWD-relative path.

#### UI-012 — Agent `approve`/`deny`/`revoke` and account `activate` skip the per-IP escalation rate-limit
- Location: `src/settings/server.ts:1448, 1475, 1488, 1592`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: The escalation routes are gated by `escalationLimiter.check(`${ip}:approve`)` (20/min). The analogous agent-grant and account-activate routes only require CSRF, falling back to the generic 120/min limiter.
- Suggested next step: Apply `escalationLimiter` to the agent approve/deny/revoke and account-activate routes too.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The agent `approve`/`deny`/`revoke` routes and the account `activate` route now each call `escalationLimiter.check(`${ip}:…`)` (20/min) before the CSRF check, matching the escalation routes.

#### UI-013 — Approve-with-conditions modal trusts client-decoded JSON in `data-conds`
- Location: `src/settings/shell.ts:235, 584-591`
- Category: Security
- Severity: Low
- Confidence: Suspect
- Description: `const condsJson = esc(JSON.stringify(g.conditions || null));` uses the four-replacement local helper. If conditions later become user-controllable, a crafted string could be unboxed unsafely.
- Suggested next step: Use the strict `escHtml` for `condsJson` and add a server-side schema check.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Both halves are already satisfied: the local `esc` used for `condsJson` is now the full 5-replacement helper (UI-004, v3.0.57), and the server-side `/api/agents/:id/approve` route validates `conditions` against a key whitelist with per-value type checks (UI-011, v3.0.60). The client never round-trips unvalidated condition data.

#### UI-014 — Settings shell HTML served on path "/" even in LAN mode without the access token
- Location: `src/settings/server.ts:566-571`
- Category: Security
- Severity: Low
- Confidence: Confirmed
- Description: The LAN-mode token gate is skipped when `path !== "/"`. A LAN device that GETs `/` receives the full HTML including the per-process CSRF token.
- Suggested next step: Require the token on `/` too when `lan && accessToken`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The GET `/` handler now calls a new `hasValidBootstrapToken(req, url, accessToken)` in LAN mode. Because a top-level browser navigation cannot send the `X-Access-Token` header, the bootstrap check accepts the token from EITHER the header (SPA re-fetch) OR the documented `?token=` query (constant-time compared); API endpoints stay strictly header-only. A LAN device that GETs `/` without the token now receives 401 instead of the CSRF-token-bearing HTML.

#### UI-015 — Tray menu shows "Open Settings" when `_settingsUrl` is empty after a failed bind
- Location: `src/index.ts:1698-1701, 1748-1752`
- Category: UX
- Severity: Low
- Confidence: Likely
- Description: After EADDRINUSE, `_settingsEnabled` stays whatever it was. State is fragile across rapid toggling.
- Suggested next step: After every state change, run `_rebuildTray()` and assert `_settingsEnabled === !!_settingsUrl`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** Every failed-bind path (EADDRINUSE-exhausted and generic) now calls a new `_markSettingsUnavailable()` that sets `_settingsEnabled = false`, clears `_settingsUrl`, and rebuilds the tray — so the `_settingsEnabled === !!_settingsUrl` invariant holds and "Open Settings" can't appear with an empty URL.

#### UI-017 — Webhook dispatcher CloudEvents payload propagates raw `clientName` to every endpoint
- Location: `src/notifications/webhooks.ts:127-156`
- Category: Security
- Severity: Low
- Confidence: Likely
- Description: `buildPayload` interpolates `g.clientName` directly into the Slack `text` and Discord `content` fields. A malicious DCR client_name like `"@here <https://evil/|click me>"` produces a real `@here` ping and a deceptive link.
- Repro hypothesis: Register MCP client with `client_name="<@everyone>"`; the next grant-created webhook to Discord pings the channel.
- Suggested next step: Strip Slack/Discord control sequences from `clientName` before formatting.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `buildPayload` runs `clientName` through `neutralizeChatMentions` for the slack/discord formats — `@` is split with a zero-width joiner (breaks `@here`/`@everyone`/`<@id>`), and `<`/`>`/backtick are replaced so a `<url|label>` link can't render. The raw/CloudEvents payloads carry the name as inert JSON data.

#### UI-016 — Tab HTML builder treats string params as "already safe HTML" without type-system support
- Location: `src/settings/server.ts:628`, `src/settings/tabs/setup.ts:118`, `src/settings/tabs/wizard.ts:163`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: `certPlatformHint` is built with sub-expressions through `escHtml`, but is then injected raw at multiple sites. If a future maintainer adds an unescaped insertion to `certPlatformHint`, the chain silently propagates.
- Suggested next step: Brand "safe HTML" strings via a tagged template or nominal `SafeHtml` type.

> **Acknowledged in v3.0.64 — by design: no SafeHtml type today.** Branding pre-escaped strings via a nominal `SafeHtml` type / tagged template is a speculative abstraction; the current `certPlatformHint` sub-expressions are all built through `escHtml` and injected at vetted sites.

#### UI-018 — Audit-log `audit-event-` CSS class is built from `escHtml(e.event)` instead of an allowlist
- Location: `src/settings/shell.ts:1932`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: `const cls = 'audit-event-' + escHtml(e.event);`. `escHtml` is XSS-safe but produces a CSS class name from arbitrary server data. The allowed set is small (requested/approved/denied/expired); should be an explicit switch.
- Suggested next step: Replace with an allowlist switch.

> **Resolved in v3.0.64 — Info triage.** The `audit-event-` CSS class is now built from an explicit `{requested,approved,denied,expired}` allowlist (unknown events → `audit-event-other`) instead of `escHtml(e.event)`.

**UI-NATIVE patterns:** two recurring patterns dominate. First, **escape-helper drift**: the codebase has a canonical 5-replacement `escHtml`, a 4-replacement `escapeHtml` in server.ts (no `'`), and three local 3-replacement `esc` closures in shell.ts (no `>`, no `'`). Each is correct in its current context, but divergence is invisible to readers and one copy-paste away from a real XSS. Second, **process-exit shortcuts ignore lifetime ownership**: `/api/shutdown`, `_onRestartRequested`, and various tray callbacks all reach for `process.exit` without running the cleanup the owning module would run on a `quit` tray click. The settings-server, MCP daemon, and standalone settings entry each own different sub-resources, and the abrupt-exit paths bypass their teardown — explaining both the tray-process leaks and the EADDRINUSE retries on quick restarts.

### Test quality

#### TEST-001 — Default fetch mock turns the source-folder UID space into a wildcard
- Location: `src/services/imap-operations.test.ts:37-50, 435-451`
- Category: Correctness
- Severity: Critical
- Confidence: Confirmed
- Description: `defaultFetchMock()` yields a `{uid}` message for *every* numeric token in the requested range, so any `client.fetch(range)` succeeds. The legacy "batch-moves emails grouped by folder" test asserts `bulkMoveEmails(["80","81"], "Trash")` returns `success=2` even though the mock never validates whether UIDs 80/81 actually live in the locked source folder. This is exactly the 3.0.41 pattern.
- Repro hypothesis: Reintroduce the 3.0.41 bug (skip the UID-existence pre-flight when `sourceFolder` is absent) — these tests still pass green. Only the newer `sourceFolder` regression suite (L1596-1693) would catch it.
- Suggested next step: Replace the default fetch mock with `fetchYielding([])` semantics for any test that doesn't explicitly seed a UID into the locked folder.
> **Resolved in v3.0.51 — Batch 7.** Replaced `defaultFetchMock()` with a per-test `seedUids(client, folder, uids)` helper backed by a `MockClientState` table. Default is empty (no UIDs in any folder); the `fetch` mock now only yields UIDs seeded into the currently-locked folder. 40 legacy tests updated with explicit seeding.

#### TEST-002 — `messageMove` mock ignores the source mailbox lock
- Location: `src/services/imap-operations.test.ts:54-63, 191-211, 442-451`
- Category: Correctness
- Severity: Critical
- Confidence: Confirmed
- Description: `makeClient` returns `messageMove: vi.fn().mockResolvedValue(undefined)` with no awareness of which folder `getMailboxLock` was called with. A test that locks INBOX and moves UID 21 to Sent silently passes whether the production code locks the right mailbox or not. The newer `sourceFolder` suite asserts the lock target, but older single-arg tests never assert `getMailboxLock` was called with the *correct* source.
- Repro hypothesis: Change `moveEmail` to hardcode `getMailboxLock("INBOX")` regardless of the email's true folder; tests at L191-211 still pass because the email was *cached* as INBOX.
- Suggested next step: For every move/copy/delete test, assert `getMailboxLock` was called with the email's actual folder, and have the `messageMove` mock reject when the lock target doesn't contain the requested UID.
> **Resolved in v3.0.51 — Batch 7.** The mock client now tracks `lockedFolder` (set by `getMailboxLock`, cleared by `lock.release()`); `messageMove` / `messageDelete` / `messageFlagsAdd` / `messageFlagsRemove` / `messageCopy` reject when the requested UID isn't seeded into the locked folder. Combined with TEST-001's per-folder seed table this means a test that locks INBOX and moves a UID seeded only in Sent now fails loudly.

#### TEST-003 — "Sanitizes" tests never inspect the sanitized output
- Location: `src/services/simple-imap-service.newfeatures.test.ts:292-317, 333-346`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: The CRLF-injection test only asserts `result.success === true` and `append.toHaveBeenCalled()` — it never reads back the MIME buffer passed to `client.append` to verify that `X-Injected: yes` / `X-Evil: header` were stripped. If sanitization is regressed to a no-op, these tests stay green.
- Repro hypothesis: Comment out the sanitizer regex in `saveDraft`; both tests still pass.
- Suggested next step: Pull the first arg of `client.append.mock.calls[0]` and assert `.toString()` does NOT contain `"X-Injected"`, `"X-Evil"`, or the literal CR/LF in header positions.
> **Resolved in v3.0.51 — Batch 7.** Both tests now pull the raw MIME Buffer from `appendMock.mock.calls[0][1]` and assert that the injected text never appears as its own MIME header line (`\r?\n\s*X-Injected:` / `\r?\n\s*X-Evil:`), the smuggled `\x01` control char is gone, and the visible filename / Message-ID tokens survive — pinning behaviour, not just absence.

#### TEST-004 — `reminder-service.prune()` test depends on wall-clock date
- Location: `src/services/reminder-service.test.ts:108-116`
- Category: Tech-debt
- Severity: High
- Confidence: Confirmed
- Description: The test seeds a record dated 2026-01-01, fires it at 2026-02-01, then calls `svc.prune(30)` which compares against `Date.now()`. The assertion `removed >= 1` only holds when the system clock is past ~2026-03-03. No `vi.useFakeTimers()` is used.
- Repro hypothesis: `TZ=UTC faketime '2026-02-15' npm test` → assertion fails.
- Suggested next step: Mock `Date.now`/use `vi.setSystemTime` before calling `prune`.
> **Resolved in v3.0.51 — Batch 7.** Wrapped the test in `vi.useFakeTimers()` + `vi.setSystemTime(new Date("2026-03-15T00:00:00Z"))` (cleanup in `finally` block); the prune now consistently sees the old fired record as outside the 30-day window regardless of the host's wall clock.

#### TEST-005 — Keychain suite has zero positive-path coverage
- Location: `src/security/keychain.test.ts:1-85`
- Category: Tech-debt
- Severity: High
- Confidence: Confirmed
- Description: Every test asserts the "keytar not installed" path. The branch where keytar *is* present and the credentials round-trip succeeds is unreachable. The interesting failure modes (encrypted save/restore, service-name collisions, keychain-locked errors) are never exercised.
- Repro hypothesis: Break the happy-path keychain logic — these tests can't detect it because they never enter that code path.
- Suggested next step: Add a parallel suite that `vi.mock('keytar', ...)` with a stub backend and exercises the populated path.
> **Resolved in v3.0.51 — Batch 7.** Added a parallel `describe('Keychain (positive-path with stub @napi-rs/keyring) — TEST-005', ...)` block. The keychain.ts module uses `new Function("specifier", "return import(specifier)")` to keep `@napi-rs/keyring` optional, which is opaque to `vi.mock`; a new exported `__setKeyringForTests(stub | null)` test-only hook (paired with the existing `__resetKeyringCacheForTests`) lets the suite inject a fresh stub Entry backend in `beforeEach` so previously-set "entries" don't leak. Six new tests cover: `isKeychainAvailable=true` when backend resolves, `save→load` round-trip with call-arg assertions on the stub, aux credential round-trip (CRED-001), full six-field `migrateFromConfig` migration (password / smtpToken / remoteBearerToken / remoteOauthAdminPassword / passAccessToken / simpleloginApiKey blanked on disk + `credentialStorage` promoted to `keychain` + `saveFn` invoked exactly once), overwrite-on-collision, and `deleteCredentials` clearing both the stub state and the in-memory entries.

#### TEST-012 — 32 of 72 MCP tools have no E2E scenario coverage
- Location: `src/tools/*.ts` vs `test/e2e/scenarios/`
- Category: Tech-debt
- Severity: High
- Confidence: Confirmed
- Description: Cross-referencing tool registrations against `call("X", ...)` invocations: untested by E2E include `send_email`, `reply_to_email`, `forward_email`, `send_test_email`, `schedule_email`, `cancel_scheduled_email` (positive path), `cancel_reminder` (positive path), `remind_if_no_reply`, `check_reminders`, `list_proton_scheduled`, all six `alias_*`, all three `pass_*`, `download_attachment`, `extract_action_items`, `extract_meeting`, `get_correspondence_profile`, `fts_rebuild`, `move_to_folder`, `move_to_spam`, `remove_label`, `request_permission_escalation`, `check_escalation_status`, plus `restart_server`/`shutdown_server`/`start_bridge`. The SMTP send-path is the largest gap.
- Repro hypothesis: A regression in `send_email` (e.g. forgetting to call `transporter.sendMail`) would not be caught.
- Suggested next step: Add a `sending.e2e.test.ts` that uses Greenmail's SMTP receiver; add scenarios for alias/pass tools (or `it.skip("requires SimpleLogin/Pass — bridge-only")`).
> **Resolved in v3.0.51 — Batch 7 (SMTP gap closed; alias/pass remain skip-stubbed).** Added `test/e2e/scenarios/sending.e2e.test.ts` with four Greenmail-backed scenarios for `send_email` (simple text body cross-user delivery, To/Cc/Bcc header propagation incl. Bcc absence-from-wire, HTML body with text/html assertion) and `send_test_email`. Each opens a second IMAP connection as Bob to verify the message actually arrived — the property that catches false-success counters. Two minimal opt-in production changes to make this work against Greenmail: `smtp-service.ts` drops `requireTLS` when `allowInsecureBridge` is set (mirrors IMAP TLS-pinning opt-in; default production behaviour unchanged), and a new optional `MAILPOUCH_SMTP_FROM` env var supplies a domain-qualified From so nodemailer doesn't build `MAIL FROM:<>` from the bare Greenmail login (production never sets this env var). Skip-with-reason placeholders added for `alias_*` (`MAILPOUCH_E2E_SIMPLELOGIN=1` gate) and `pass_*` (`MAILPOUCH_E2E_PASS=1` gate) so the audit gap stays visible. The remaining tool coverage (`schedule_email`, `cancel_scheduled_email` positive, `remind_if_no_reply`, `check_reminders`, `download_attachment`, `extract_action_items`, etc.) is out of scope for Batch 7 — tracked for a follow-on batch.

#### TEST-006 — `imap-operations.test.ts` imports `beforeEach` but never uses it
- Location: `src/services/imap-operations.test.ts:11`
- Category: Concurrency
- Severity: Medium
- Confidence: Confirmed
- Description: No `vi.restoreAllMocks()`, no module-state reset between tests.
- Suggested next step: Add `beforeEach(() => vi.restoreAllMocks())` or delete the unused import.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** Added a top-level `beforeEach(() => vi.restoreAllMocks())` to `imap-operations.test.ts`, putting the previously-unused import to work and resetting spy/module state between tests.

#### TEST-007 — Mock drift in `getFolders` spy shape
- Location: `src/services/imap-operations.test.ts:393-395, 416-418`, `src/services/imap-fetch.test.ts:93-95, 118-120`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: The mocked `getFolders` returns `{name, path, totalMessages, unreadMessages, folderType}` — but the real `EmailFolder` interface includes `specialUse`. Any code path that reads `folder.specialUse` will get `undefined` in tests while the real impl populates it.
- Suggested next step: Centralize a `makeFolder()` helper that produces the real shape.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** Added a `makeFolder()` helper to `imap-operations.test.ts` that produces the full `EmailFolder` shape including `specialUse`, and applied it at the `getFolders` spy return sites so the mock no longer drifts from the interface.

#### TEST-008 — Agent-harness `expect(outcome).toBeDefined()` tests only that Bridge responded
- Location: `test/agent-harness.test.ts:413, 491-492, 533, 544`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: Several harness tests pattern as: `const outcome = await callRaw("X", {...}); expect(outcome).toBeDefined();`. Since `callRaw` always resolves with an object, the assertion is a tautology.
- Suggested next step: Assert the shape of the data payload, or remove the smoke tests entirely.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** Added an `assertWellFormed(outcome)` helper and replaced the tautological `expect(outcome).toBeDefined()` smoke assertions with it — it asserts the discriminated shape (success ⇒ `content` array; failure ⇒ non-empty `message`).

#### TEST-009 — Permission-escalation test uses hardcoded `/tmp/test-pending-new.json`
- Location: `src/permissions/escalation.test.ts:92-99`
- Category: Concurrency
- Severity: Medium
- Confidence: Confirmed
- Description: The test writes to a fixed `/tmp/test-pending-new.json` path instead of `mkdtempSync(...)`. Two parallel CI runs collide. Env vars also aren't try/finally restored.
- Suggested next step: Use `mkdtempSync(tmpdir(), 'escalation-env-')` + `afterEach` cleanup.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** The env-override test now uses `mkdtempSync(join(tmpdir(), 'escalation-env-'))` for its paths and restores `MAILPOUCH_PENDING`/`MAILPOUCH_AUDIT` via try/finally, so parallel CI lanes no longer collide on a fixed `/tmp` path.

#### TEST-010 — `MAILPOUCH_INSECURE_BRIDGE=1` global setup masks the strict TLS path
- Location: `src/test-setup.ts:12`, `src/services/connect-tls.test.ts:104-112`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: Setup forces the insecure opt-in for the whole unit suite. Any future strict-mode test in another file inherits the global "1" and never exercises strict semantics.
- Suggested next step: Flip the default — strict by default, individual files opt in via `beforeEach`.
> **Acknowledged in v3.0.61 — by design (with mitigation):** a full strict-by-default flip churns several connect-path test files (`folder-management`, `smtp-tls`, `imap-operations`, `bridge-version`) and several deliberately exercise the localhost-insecure path, so the risk/reward of the flip is poor for a test-quality item. Instead, `test-setup.ts` now documents the strict-test opt-out contract (clear the env var in a `beforeEach`/`afterEach`, per `connect-tls.test.ts`'s canonical "strict mode" block), which is the actual mitigation for a *new* strict test silently inheriting `"1"`.

#### TEST-011 — Hardcoded Greenmail ports prevent parallel CI lanes
- Location: `test/e2e/support/docker.ts:19-20`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: `GREENMAIL_IMAP_PORT = 3143` and `3025` are baked in. Two PRs running e2e in parallel on the same self-hosted runner collide.
- Suggested next step: Pick an ephemeral port; export the URL to the harness.
> **Acknowledged in v3.0.61 — deferred:** ephemeral ports require dynamic docker-compose port allocation + threading the resolved URL through the harness, an E2E-infra change that can't be validated without a Docker/Greenmail runner (unavailable in this sweep's environment). Deferred to avoid shipping an unverified harness change.

#### TEST-015 — Empty-array vs missing-array confusion in destructive-gate tests
- Location: `test/agent-harness.test.ts:470-483`
- Category: Correctness
- Severity: Medium
- Confidence: Likely
- Description: The "bulk_delete without confirmation is blocked or gated" test accepts any of three outcomes. If the implementation accidentally returned `{ok:true, isError:false, content:[{text:"{\"success\":0,\"failed\":0}"}]}` (silent no-op), the disjunction fails to catch it.
- Suggested next step: Tighten to "MUST be an error or MUST set `requiresConfirmation`", and additionally assert no IMAP state changed.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** The destructive-gate harness tests now require an *explicit* refusal via an `isExplicitlyGated()` helper (permission block, MCP error, or an `isError` result naming confirm/dangerous/preview) — a bare success such as `{success:0,failed:0}` no longer counts as "gated".

#### TEST-013 — `fts-service.test.ts` silently degrades to zero coverage when sqlite is absent
- Location: `src/services/fts-service.test.ts:14-25, 44`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: `describeMaybe` is `describe.skip` when `require("better-sqlite3")` throws. On a minimal CI runner without native build deps, the entire FTS suite skips quietly.
- Suggested next step: Make sqlite a hard dependency in CI, and fail the suite if `sqliteAvailable()` returns false instead of silent skip.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** `fts-service.test.ts` now throws (failing the suite) when `better-sqlite3` is unavailable *and* `process.env.CI` is set, while still allowing a local skip for the dev loop. So a CI runner missing native build deps fails loudly instead of silently dropping the entire FTS surface.

#### TEST-014 — Agent-harness "discovery" test allows the tool surface to silently shrink
- Location: `test/agent-harness.test.ts:106-133`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: The discovery test checks for a hand-picked subset of 9 tools and asserts `tools.length >= 40`. The real surface is 72 tools.
- Suggested next step: Generate the expected list from `src/tools/*.ts` registrations.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** The discovery test now imports `allToolDefs()` from the registry and asserts two real invariants instead of a hand-picked subset + `>= 40` floor: (1) every served tool exists in the registry (catches ghosts/typos), and (2) the served count is non-empty and ≤ the registry size. (Exact equality isn't asserted because the served list is permission-tier filtered in the ListTools handler.)

#### TEST-019 — Bridge-only `it.skip` calls form a parallel uncovered surface
- Location: `test/e2e/scenarios/labels.e2e.test.ts:43`, `folders.e2e.test.ts:44`, `search.e2e.test.ts:59`, `deletion.e2e.test.ts:65`, `reading.e2e.test.ts:65,99`, `actions.e2e.test.ts:310,373,380`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: 9 scenarios are `it.skip` with "bridge-only" comments. If the bridge-only harness ever stops running (CI matrix change, Bridge cert expiry), none of these run anywhere.
- Suggested next step: Build a registry mapping each `bridge-only` skip → its counterpart test, fail CI if the counterpart is also `skip`/missing.
> **Acknowledged in v3.0.61 — deferred:** the Phase-2 `bridge-only` counterpart suite referenced by these skips does not exist yet, so a registry enforcing counterpart presence would hard-fail CI today rather than improve coverage. Blocked on building the bridge-only harness (out of scope for this low-severity sweep).

#### TEST-024 — No test asserts `confirmed:true` gate at MCP layer (only the destructive call layer)
- Location: `test/e2e/scenarios/deletion.e2e.test.ts:43-77`, `actions.e2e.test.ts:335-349`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: E2E asserts `delete_email` without `confirmed:true` returns `isError`. But no test verifies the gate fires *before* any IMAP work begins. A bug that calls `messageDelete` first then refuses to return success would be silently destructive on Bridge.
- Suggested next step: After a confirmed:false rejection, assert IMAP has the message *and* assert no `messageDelete` happened.
> **Resolved in v3.0.61 — IMAP/SMTP low-severity sweep.** The deletion E2E "rejects when confirmed flag is missing" test now snapshots the INBOX message count before the rejected `delete_email` and asserts *both* `uidExists === true` and the count is unchanged — observable evidence that the gate fires before any IMAP mutation. (The destructive-confirmation gate already runs before handler dispatch in `index.ts`.)

#### TEST-016 — `oauth-store.test.ts` mutates token internals to simulate expiry instead of mocking time
- Location: `src/transports/oauth-store.test.ts:64, 85, 106`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Tests cast the returned token to `{createdAt: number}` and reassign `Date.now() - TTL - 1` to fake expiry. Bypasses encapsulation.
- Suggested next step: Switch to `vi.useFakeTimers()` + `vi.advanceTimersByTime(TTL + 1)`.

> **Resolved in v3.0.63 — test-quality sweep (#174).**

#### TEST-017 — `grant-store` prune tests backdate via `Date.now() - n*day`
- Location: `src/agents/grant-store.test.ts:114, 124`
- Category: Tech-debt
- Severity: Low
- Confidence: Likely
- Description: Susceptible to wall-clock drift in copy-paste situations.
- Suggested next step: Use `vi.setSystemTime` and pass explicit "fixed now" anchors.

> **Resolved in v3.0.63 — test-quality sweep (#174).**

#### TEST-018 — `analytics-service.test.ts` uses `toBeDefined` where shape matters
- Location: `src/services/analytics-service.test.ts:92-95, 155, 423, 741`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Accepts any truthy value, including `null` or `0`.
- Suggested next step: Use `toEqual(expect.objectContaining({...}))` or assert specific types.

> **Resolved in v3.0.63 — test-quality sweep (#174).**

#### TEST-020 — E2E `smoke.e2e` "listTools" only spot-checks 4 names
- Location: `test/e2e/scenarios/smoke.e2e.test.ts:32-40`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Same anti-pattern as agent-harness discovery test.
- Suggested next step: Snapshot the tool list and `toMatchSnapshot()`.

> **Resolved in v3.0.63 — test-quality sweep (#174).**

#### TEST-021 — Bulk-action `errors[0]` string-matching assertions are over-loose
- Location: `src/services/imap-operations.test.ts:481, 515, 530, 596, 617, 632`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Multi-error scenarios only assert `errors[0]`, never `errors.length`.
- Suggested next step: Assert both `errors.length === failed` and each entry's content.

> **Resolved in v3.0.63 — test-quality sweep (#174).**

#### TEST-022 — No test asserts cache-key compound `folder:uid` format outside move/delete
- Location: `src/services/imap-operations.test.ts:449-450, 566-567`
- Category: Correctness
- Severity: Low
- Confidence: Suspect
- Description: If the cache key format ever drifts, the move/delete tests catch it but the flag-update tests rely on the helper's compound-internal logic.
- Suggested next step: Add a single explicit "cache key format" test that locks the wire format.

> **Resolved in v3.0.63 — test-quality sweep (#174).**

#### TEST-023 — `test/integration.test.ts` is unit tests in disguise
- Location: `test/integration.test.ts:1-60`
- Category: Tech-debt
- Severity: Info
- Confidence: Confirmed
- Description: File lives at `test/integration.test.ts`, suggesting cross-module verification. Actual content is pure helpers with no real I/O.
- Suggested next step: Either grow it into a real integration test or rename/inline.

> **Acknowledged in v3.0.64 — by design: name is harmless.** `test/integration.test.ts` exercises pure helpers; renaming/relocating it is churn with no coverage gain. Real cross-module verification lives in the Greenmail E2E suite under `test/e2e/`.

#### TEST-025 — Harness retry budget is zero, so flakes look like real failures
- Location: `vitest.config.e2e.ts:18`
- Category: Tech-debt
- Severity: Info
- Confidence: Likely
- Description: `retry: 0` is intentional but combined with Greenmail's documented IDLE/EXPUNGE races, every transient race burns a CI run.
- Suggested next step: Either keep retry=0 and tag known-flaky with `it.skipIf(process.env.CI)`, or set `retry: 1` for e2e only.

> **Acknowledged in v3.0.64 — by design: retry=0 is intentional.** Keeping `retry: 0` surfaces Greenmail IDLE/EXPUNGE races loudly rather than masking them; known-flaky scenarios are tagged at the test level rather than globally bumping the retry budget.

**TEST-QUALITY patterns:** the unit suite leans hard on `vi.fn().mockResolvedValue(undefined)` for IMAP verbs, which collapses the per-folder UID space into a single happy path — the 3.0.41 class regression is only caught by the *newer* `sourceFolder` block; the *legacy* paths remain unguarded. There is a recurring "sanitizes X" idiom that asserts `success:true` but never reads back the sanitized output (TEST-003), and a parallel "tool responded" idiom in the agent harness that proves only Bridge is up (TEST-008, TEST-014). Time and TLS state both leak via `process.env` (TEST-004, TEST-010). The most useful next investment is probably a `makeImapClientMock()` helper that models a real per-folder UID table.

### Build, CI, supply chain

#### BUILD-001 — `prepare` runs `npm run build` on every consumer install (publish-time-only intent)
- Location: `package.json:33`
- Category: Correctness
- Severity: High
- Confidence: Confirmed
- Description: `"prepare": "npm run build && simple-git-hooks"` fires in two contexts npm distinguishes. Because `typescript` is a devDep, a `git`-installed mailpouch will run `simple-git-hooks` in the consumer's repo too — wrong directory + silent surprise.
- Repro hypothesis: `npm i github:chandshy/mailpouch` in any random repo: `tsc` runs (devDeps present from `prepare`), then `simple-git-hooks` writes `.git/hooks/pre-push` in the consumer's repo.
- Suggested next step: Split into `prepare` (build only) and `postinstall` gated to detect the local-repo case, or move hook install to a manual `setup:hooks` script.

> **Resolved in v3.0.49 — Batch 9.** `prepare` is now build-only; a new `postinstall` delegates to `scripts/install-hooks.mjs`, which installs `simple-git-hooks` only when `INIT_CWD === process.cwd()` AND `.git/` exists at the package root. Downstream consumer installs (`pnpm add mailpouch`, `npm i github:chandshy/mailpouch`) no longer write git hooks into the consumer's repo.

#### BUILD-011 — `npm publish` workflow lacks `npm ci --ignore-scripts`; lifecycle scripts run with provenance signing keys in scope
- Location: `.github/workflows/publish.yml:26-50`
- Category: Security
- Severity: High
- Confidence: Likely
- Description: `npm ci` runs all `preinstall`/`install`/`postinstall` lifecycle scripts of every transitive dep, including optional-dep native build scripts. This runs in the same job that holds the npm provenance OIDC token and `NPM_TOKEN`. A compromise of any transitive (next `event-stream`) executes arbitrary code with publish-grade secrets in env.
- Repro hypothesis: A transitive dep with a malicious `postinstall` ships `~/.npmrc` to a remote.
- Suggested next step: Run `npm ci --ignore-scripts` then a separate `npm rebuild` for the natives you trust. Rotate `NPM_TOKEN` to a granular-scoped token.

> **Resolved in v3.0.49 — Batch 9.** Both jobs in `.github/workflows/publish.yml` now run `npm ci --ignore-scripts`, followed by an explicit `npm rebuild better-sqlite3 @napi-rs/keyring` — the only natives mailpouch's prod surface needs. Transitive `postinstall` shells no longer execute with `NPM_TOKEN` / OIDC publish keys in scope. `NPM_TOKEN` rotation is out of scope for this PR (environment-level concern).

#### BUILD-012 — Third-party GitHub Actions pinned to floating tags, not commit SHAs
- Location: `.github/workflows/build-native-tray.yml:73,151`, `ci.yml:35-39`, `preship.yml:54-56,94`, `publish.yml:17-20,64-67`
- Category: Security
- Severity: High
- Confidence: Confirmed
- Description: All actions reference floating tags: `actions/checkout@v4`/`@v6`, `actions/setup-node@v4`/`@v6`, `actions/upload-artifact@v4`/`@v7`, `dtolnay/rust-toolchain@stable`. GitHub's own supply-chain guidance is to pin to a 40-char SHA because tags are mutable. `dtolnay/rust-toolchain@stable` is especially weak — pinned to a *branch name*. Version pinning is inconsistent across workflows.
- Repro hypothesis: Attacker compromises `dtolnay/rust-toolchain` and moves the `stable` ref.
- Suggested next step: Pin every action to `@<full-sha>` with a `# v4.2.2` style trailing comment; standardize versions across workflows.

> **Resolved in v3.0.49 — Batch 9.** All 13 `uses:` lines across `ci.yml`, `preship.yml`, `publish.yml`, and `build-native-tray.yml` are pinned to 40-character SHAs with `# vX.Y.Z` trailing comments. Versions standardized: `actions/checkout@de0fac2e...` (v6.0.2), `actions/setup-node@48b55a01...` (v6.4.0), `actions/upload-artifact@043fb46d...` (v7.0.1), `dtolnay/rust-toolchain@29eef336...` (branch HEAD of `stable` as of 2026-05-29 — the action does not cut tags, so the comment annotates the date instead). `preship.yml`'s `@v4` references were bumped to the same v6/v7 SHAs as the other workflows. SHA pins prevent silent action-tag-tampering: a compromised action repo can no longer reroute `actions/checkout@v6` to malicious code on our next workflow run.

#### BUILD-004 — `license-inv` baseline records `version: "(unknown)"` and `license: "UNKNOWN"` for legit prod deps
- Location: `scripts/check-licenses.mjs:37-66`, `LICENSES.json:5-9`
- Category: Correctness
- Severity: High
- Confidence: Confirmed
- Description: `npm ls --omit=dev --long` returns nodes without `version` for peer/extraneous links; the committed baseline records `@cfworker/json-schema` and `@napi-rs/keyring-darwin-arm64` as `(unknown)/UNKNOWN`. The dedupe key is `name@(unknown)`. Future installs that resolve a real version look like a *add* + *remove* — noisy false drift, and the inverse case (real version becomes unknown later) masks a real dep swap.
- Repro hypothesis: Re-run `node scripts/check-licenses.mjs` after a fresh `rm -rf node_modules && npm ci` — likely produces drift for the unknown-recorded packages.
- Suggested next step: Filter out entries where `version === "(unknown)"` and surface them as a separate "skipped" diagnostic line.
> **Resolved in v3.0.43 — preship gate hotfix (#130).** Verified: `check-licenses.mjs` partitions `flat` into `usable` (kept) and `skipped` (`version === "(unknown)"`, excluded from the baseline + drift comparison and surfaced as a separate diagnostic line); the baseline diff also filters `(unknown)` entries on the read side.

#### BUILD-002 — `npm-version-free` advisory check inverted on parse error
- Location: `scripts/preship.mjs:99-106`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: `successWhen: ({ code, output }) => code !== 0 || !output.trim()` says "pass when npm view fails OR returns empty." If the npm registry is briefly unreachable, the gate happily declares "version not yet on npm" — when in fact it had no idea.
- Suggested next step: Distinguish "registry says no such version" (E404) from "registry unreachable" (other failures).
> **Resolved in v3.0.43 — preship gate hotfix (#130).** Verified: the `npm-version-free` step in `scripts/preship.mjs` now treats exit 0 + version output as "ALREADY PUBLISHED" (`ok:false`), an E404/"not found" as "not yet published" (`ok:true`), and any other non-zero (registry unreachable) as "could not verify" (`ok:false`) — no longer false-greens on an unreachable registry.

#### BUILD-003 — `npm audit` script may miscategorize advisories with `severity: unknown`
- Location: `scripts/check-npm-audit.mjs:35-69`
- Category: Security
- Severity: Medium
- Confidence: Likely
- Description: The bucket comparison only matches exact strings — anything that comes back as `"info"` or `"unknown"` is silently dropped.
- Suggested next step: Add an explicit `else` branch that prints any finding bucketed neither as high/critical nor moderate/low.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Added an `other` bucket for severities outside high/critical/moderate/low (e.g. `info`/`unknown`); these are now printed and exit 2 (advisory) rather than silently dropped.

#### BUILD-005 — `license-inv` first-run silently writes the baseline *and* fails the gate
- Location: `scripts/check-licenses.mjs:69-78`
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: On first run without `PRESHIP_LICENSE_WRITE=1`, the script *generates and stages* a baseline file behind the user's back AND exits 1. Inside CI's working tree, this leaves `git status`-dirty.
- Suggested next step: First-run path should error with "no baseline; run with PRESHIP_LICENSE_WRITE=1" and *not* write the file as a side effect.
> **Resolved in v3.0.43 — preship gate hotfix (#130).** Verified: when `LICENSES.json` is absent, `check-licenses.mjs` errors actionably ("no baseline … generate one with `PRESHIP_LICENSE_WRITE=1`") and exits 1 without writing the file; the baseline is only written under the explicit `PRESHIP_LICENSE_WRITE=1` flag.

#### BUILD-006 — `tarball-smoke` only validates `--version` (skips `--omit=optional`); native-tray import path not exercised
- Location: `scripts/smoke-tarball.mjs:50-90`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: Passes `--omit=optional` so neither `@napi-rs/keyring` nor `systray2` get installed. Then runs `node $entry --version` which short-circuits in `src/index.ts:1786` before the tray module loads. The smoke therefore validates "bin path resolves and prints version," but does NOT validate that `files` actually shipped `native/tray/index.js`, `.d.ts`, `.node` files, or that `_loadNativeTray()` can resolve them.
- Repro hypothesis: Remove `"native/tray/index.js"` from `files` array; smoke still passes because `--version` exits early.
- Suggested next step: After `--version` boot, invoke a second `node -e "require('mailpouch/dist/utils/tray.js').preflightTrayBinary()"`, or assert via `tar -tzf` that all `files` globs match.
> **Resolved in v3.0.43 — preship gate hotfix (#130).** Verified: after the `--version` boot, `smoke-tarball.mjs` runs `tar -tzf <tarball>` and asserts the packed set contains `package/native/tray/index.js`, `…/index.d.ts`, `package/dist/index.js`, `…/settings-main.js`, and `…/utils/tray.js` — so a dropped `files` entry now fails the gate.

#### BUILD-008 — `tsconfig.json` missing several strict-mode helpers
- Location: `tsconfig.json:9-13`
- Category: Tech-debt
- Severity: Medium
- Confidence: Confirmed
- Description: `"strict": true` covers many flags, but `noUncheckedIndexedAccess`, `noImplicitOverride`, `exactOptionalPropertyTypes`, and `isolatedModules` are unset. `noUncheckedIndexedAccess` in particular would have caught the IMAP UID bug class.
- Suggested next step: Turn on `noUncheckedIndexedAccess` in a follow-up PR; fix the resulting errors incrementally.
> **Resolved (partial) in v3.0.60 — UI/CSP + build/docs sweep.** Enabled the two zero-error flags now: `noImplicitOverride` and `isolatedModules`. `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` cascade many errors and are deferred to a dedicated follow-up exactly as the finding recommends ("turn on in a follow-up PR; fix incrementally").

#### BUILD-009 — `package.json` ships `dist/**/*` blindly — includes `.d.ts.map`, `.js.map` with absolute source paths
- Location: `package.json:110-121`, `tsconfig.json:15-17`
- Category: Security
- Severity: Medium
- Confidence: Confirmed
- Description: `tsconfig.json` enables both `sourceMap` and `declarationMap` for dev; `files: ["dist/**/*"]` packs them all. Source maps embed absolute paths of `src/` files at pack time, leaking maintainer's directory layout.
- Repro hypothesis: `npm pack && tar -xzf mailpouch-3.0.42.tgz -C /tmp/x && grep -r '/mnt/data/Code/mailpouch' /tmp/x/package/dist | head` will list every map.
- Suggested next step: Disable `sourceMap`/`declarationMap` for production build (`tsc -p tsconfig.build.json`), or extend `files` array to glob-exclude maps.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Took the glob-exclude route: `files` now contains `!dist/**/*.js.map` and `!dist/**/*.d.ts.map`. Verified via `npm pack --dry-run` that 0 `.map` files are packed.

#### BUILD-013 — `runSteps` halts but reports skipped steps without distinguishing from intentional skip
- Location: `scripts/lib/preship-runner.mjs:34-69`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: When step N hard-fails, steps N+1..end push `{ status: "skipped" }` and the final formatter prints them as "skipped after halt" gray text. The ship skill may parse stdout to detect "all green"; skipped-after-halt could confuse it.
- Suggested next step: Use a distinct unicode mark (e.g. `↷` deferred) and color (red, not gray) for skipped-after-halt.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** Deferred-after-halt steps now render with a red `↷` mark and the literal text `deferred after halt` (table row + summary), instead of gray "skipped" — a stdout parser can no longer conflate them with an intentional skip/pass.

> **Resolved in v3.0.43 — preship gate hotfix (#130).** Verified: `scripts/preship.mjs` honours `PRESHIP_SKIP=1` at the top of the file (short-circuits with a loud `BYPASS: PRESHIP_SKIP=1` line to stderr and exits 0), and `docs/preship.md:138-139` documents the bypass for both `/ship` and `npm run preship*`.

#### BUILD-014 — `docs/preship.md` claims `PRESHIP_SKIP=1 /ship` bypass exists; no code in this repo honors it
- Location: `docs/preship.md:138`, `scripts/preship.mjs:1-121`, `scripts/lib/preship-runner.mjs:1-136`
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: The bypass table documents `PRESHIP_SKIP=1` as the canonical emergency bypass. Searching the preship code returns zero matches. Either the bypass lives in the ship skill outside this repo, or the doc is aspirational.
- Repro hypothesis: `PRESHIP_SKIP=1 npm run preship:fast` runs the full gate regardless of the env var.
- Suggested next step: Either implement `if (process.env.PRESHIP_SKIP === "1") { console.log("BYPASS"); process.exit(0); }` near `scripts/preship.mjs:23`, or remove that table row from docs.

> **Resolved in v3.0.43 — preship gate hotfix (#130).**

#### BUILD-015 — Pre-push hook (`preship:fast`) skips `tarball-smoke` and both E2E suites
- Location: `package.json:107-109`, `scripts/preship.mjs:45-61`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: `preship:fast` excludes `tarball-smoke`. The only check that catches a missing `bin` shebang, missing `files` entry, or ESM/CJS divergence is gone from the fast tier. A commit that breaks the published tarball passes pre-push silently.
- Repro hypothesis: Remove the shebang from `src/index.ts:1`, run `git push` from a feature branch. `preship:fast` is green.
- Suggested next step: Move `tarball-smoke` into the fast tier (it's ~5s) or add a `pre-commit` hook for it.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** `tarball-smoke` moved from `heavySteps` into `fastSteps`, so it now runs on every pre-push (`preship:fast`).

#### BUILD-007 — `tarball-smoke` early-exit branches `process.exit(1)` skip the accumulator pattern
- Location: `scripts/smoke-tarball.mjs:42-89`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Multiple `process.exit(1)` calls fire from inside the `try` block. Cleanup runs via `finally`, but the `exitCode = 1` accumulator pattern is inconsistent.
- Suggested next step: Switch all early-exit branches to `exitCode = 1; return;` inside the `try`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** All early-exit branches in `smoke-tarball.mjs` now `throw new Error(...)` inside the `try`, so the existing `catch` sets `exitCode = 1` and the `finally` always runs the staging/install cleanup; no bare `process.exit(1)` remains in the body.

#### BUILD-010 — `overrides.fast-xml-parser` is a phantom — package isn't in the dep tree
- Location: `package.json:122-128`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `package-lock.json` contains zero references to `fast-xml-parser`. The override exists but never matches a real dep.
- Suggested next step: Drop the `fast-xml-parser` override line.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The `fast-xml-parser` override is removed from `package.json` (`package-lock.json` has zero references to it, confirming it never matched a real dep).

#### BUILD-016 — `spawnNpmRun` passes `--silent` so failing output is suppressed
- Location: `scripts/lib/preship-runner.mjs:133-135`
- Category: UX
- Severity: Low
- Confidence: Likely
- Description: `npm run --silent` suppresses npm's own framing AND prevents npm from printing the `npm error` block on script failure. The user sees less of what failed than they would running the script directly.
- Suggested next step: Drop `--silent`, or use `--quiet` instead.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `spawnNpmRun` now runs `npm run <script>` without `--silent`, so npm's `npm error` framing surfaces on a failing script (the runner already captures and prints child stdout+stderr on failure).

#### BUILD-017 — `spawnStep` merges `process.env` into child env unconditionally
- Location: `scripts/lib/preship-runner.mjs:103-112`
- Category: Security
- Severity: Low
- Confidence: Likely
- Description: Each step's env includes `NPM_TOKEN`, `GITHUB_TOKEN`, `MAILPOUCH_E2E_BRIDGE_CONFIG`, `NODE_AUTH_TOKEN`. Defense in depth: the secrets check shouldn't have your real PAT in its env.
- Suggested next step: Pass an explicitly-allowlisted env subset per step type.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `spawnStep` now scrubs the publish-only secrets `NPM_TOKEN`/`NODE_AUTH_TOKEN`/`GITHUB_TOKEN`/`GH_TOKEN` from every child env by default (a step can opt one back in via `keepEnv`). The Bridge config env var stays available to the e2e:bridge step.

#### BUILD-018 — `test:e2e:local` shell-chain swallows the `docker compose up -d` readiness wait
- Location: `package.json:21`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: `docker compose up -d` returns 0 as soon as the container *starts*, not when it's *ready*. There's no readiness check between up and vitest run; on slow runners vitest hits IMAP-not-ready and times out.
- Suggested next step: Factor the `for i in $(seq 1 30); do (echo > /dev/tcp/127.0.0.1/3143) ...` loop from `preship.yml` into a script.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `test:e2e:local` now runs `node scripts/wait-for-port.mjs 127.0.0.1 3143 30` between `docker compose up -d` and the vitest run, so vitest only starts once Greenmail's IMAP port accepts a connection.

#### BUILD-019 — `engines.npm: ">=9.0.0"` enforced nowhere
- Location: `package.json:74-77`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `engines` is declarative — npm itself only enforces it with `--engine-strict` (off by default). No CI step asserts the runner's npm version. A consumer on npm 8.0 sees `overrides` silently ignored and pulls in vulnerable transitives.
- Suggested next step: Add a CI assertion + document npm ≥ 9 requirement, or ship a `.npmrc` with `engine-strict=true`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** A repo-local `.npmrc` with `engine-strict=true` now turns the `engines.npm: ">=9.0.0"` constraint into a hard error for anyone installing from the repo. (`.npmrc` is not in the `files` allowlist, so it doesn't ship to consumers.)

#### BUILD-020 — `check-secrets` exclude list omits `test/**` where API-key-shaped fixtures live
- Location: `scripts/check-secrets.mjs:31-39`
- Category: UX
- Severity: Low
- Confidence: Likely
- Description: A `*.test.ts` containing a fake `sk-test_xxx` for a stripe-style mock would block every push. The script has no per-rule allowlist.
- Suggested next step: Extend the exclude pathspec with `:!test/**/*.test.ts :!test/**/fixtures/**`, or add a comment-pragma allowlist.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `check-secrets.mjs` excludes `test/**/fixtures/**` and `**/__fixtures__/**` from the grep-fallback pathspecs. Real `*.test.ts` files stay scanned (so an accidental real leak in test code is still caught); only the deliberate fixtures trees are exempt.

#### BUILD-021 — `actions/setup-node` cache invalidation doesn't account for `overrides` changes
- Location: `.github/workflows/preship.yml:56-59`, `ci.yml:38-41`
- Category: Security
- Severity: Low
- Confidence: Suspect
- Description: GitHub's setup-node `cache: 'npm'` keys on `package-lock.json`. The `overrides` block lives in `package.json` and changes don't always rewrite `package-lock.json` in the same shape.
- Suggested next step: Add an explicit `actions/cache` step keyed by `hashFiles('package-lock.json', 'package.json')`.
> **Acknowledged in v3.0.63 — by design: npm writes resolved `overrides` versions into `package-lock.json`, so a meaningful override change updates the lock and invalidates `setup-node`'s `cache: 'npm'` key in practice. Adding a parallel `actions/cache` step keyed on both files would duplicate caching work for a theoretical edge (an override that resolves to an already-pinned version). Not worth the complexity at this confidence level (Suspect).**

#### BUILD-022 — `lint` and `lint:fix` are both `tsc --noEmit`; preship's `lint` step is hardcoded `ok: true`
- Location: `package.json:35-36`, `scripts/preship.mjs:49-53`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `"lint"` and `"lint:fix"` are aliases of `typecheck`. In `preship.mjs:49-53` the `lint` step is a fixed `() => ({ ok: true, summary: "alias of typecheck" })`.
- Suggested next step: Either wire `eslint` properly, or delete the slot entirely.
> **Acknowledged in v3.0.63 — by design: no ESLint config exists in this repo, and the `lint`/`lint:fix` aliases of `tsc --noEmit` plus the hardcoded `{ ok: true, summary: "alias of typecheck" }` preship slot keep the step table stable across fast/full/release until a real linter is wired (the slot is explicitly commented as a placeholder). Deleting the slot would churn the table for no behavioural gain; wiring ESLint is a deliberate follow-up, not a low-severity cleanup.**

#### BUILD-023 — `publish-gpr` workflow mutates `package.json` in CI without revert
- Location: `.github/workflows/publish.yml:79-95`
- Category: Correctness
- Severity: Low
- Confidence: Likely
- Description: An inline `node -e` rewrites `package.json` to set `name` to `@chandshy/mailpouch`, then `npm publish`. There's no revert. If a future maintainer adds a `git push tags` or `gh release upload` step, the dirty workspace propagates.
- Suggested next step: Wrap the rewrite in `cp package.json /tmp/pkg.bak; <edit>; npm publish; mv /tmp/pkg.bak package.json`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The GPR publish step now `cp package.json /tmp/pkg.bak` before the scoped-name rewrite and `mv /tmp/pkg.bak package.json` after `npm publish`, so any later step in the job sees the original workspace.

#### BUILD-024 — `mailpouch-settings` bin exposed publicly contradicts settings-UI lifecycle policy
- Location: `package.json:7-10`
- Category: UX
- Severity: Low
- Confidence: Likely
- Description: Per the user's memory: "Settings UI lifecycle — on-demand, never persistent. UI + tray are agent-scoped." Exposing `mailpouch-settings` as a top-level bin installs a globally-callable `mailpouch-settings` after `npm i -g mailpouch`, allowing persistent launches outside an MCP session.
- Suggested next step: Drop the `mailpouch-settings` bin entry; have it invoked only via `mailpouch settings` subcommand.
> **Acknowledged in v3.0.63 — by design: `mailpouch-settings` is the *documented, user-initiated* launcher for the settings UI (README and HELP both teach `npx mailpouch-settings [--lan|--port|--browser|--tui]`). The settings-UI lifecycle policy targets *persistent/autostart* launches; a user explicitly running `npx mailpouch-settings` is on-demand and agent-external by definition, not a daemon. Removing the bin would break every documented invocation with no replacement. Keeping it.**

#### BUILD-025 — `.gitignore` `/tmp/preship-pack-*` entries don't match anything
- Location: `.gitignore:50-51`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: `/tmp/preship-pack-*` and `/tmp/preship-smoke-*` are interpreted relative to repo root (the leading `/` means repo-root, not filesystem root). Nothing tracks `/tmp` in git anyway.
- Suggested next step: Remove the two `/tmp/...` lines; the `mailpouch-*.tgz` line above is correct and sufficient.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The `/tmp/preship-pack-*` and `/tmp/preship-smoke-*` lines are removed; `smoke-tarball` stages into `os.tmpdir()` (never the repo), and the `mailpouch-*.tgz` line covers a stray local `npm pack`.

**BUILD-CI-SUPPLY patterns:** the freshly shipped preship gate (`007f0ec`) is well-structured at the orchestrator level — the step dispatch, the `mode: "hard"|"advisory"` distinction, the readable summary formatter — but the *individual checks* leak abstractions. License-inv writes baselines as a side effect (BUILD-005), npm-audit drops `unknown`-severity findings (BUILD-003), tarball-smoke only validates `--version` and never touches the tray/native asset paths (BUILD-006), and the documented `PRESHIP_SKIP` bypass doesn't exist (BUILD-014). The build/CI scaffold (older, well-traveled) has classic 2024 supply-chain debt: floating action tags including `dtolnay/rust-toolchain@stable` (BUILD-012), `npm ci` without `--ignore-scripts` in the publish job that holds OIDC + NPM_TOKEN (BUILD-011), and a `prepare` script that fires on every consumer install (BUILD-001). TS strictness leaves `noUncheckedIndexedAccess` off (BUILD-008) — which would have caught the very IMAP UID bug class this release fixed. Highest-leverage next actions: pin actions to SHAs, fix license-inv first-run write, add `--ignore-scripts` to publish, exclude `.map` files from the tarball, and either implement or delete `PRESHIP_SKIP`.

### Docs, memory, version drift

#### DOCS-009 — preship.md claims `npm-audit` hard-fails on HIGH/CRITICAL; orchestrator wraps it as `advisory`
- Location: `docs/preship.md:30`, `scripts/preship.mjs:57`, `scripts/lib/preship-runner.mjs:48`
- Category: Correctness
- Severity: High
- Confidence: Confirmed
- Description: preship.md:30 table row says `npm-audit ... yes on HIGH/CRITICAL`. In `scripts/preship.mjs:57` the step is registered with `mode: "advisory"`. The runner maps any non-OK advisory step to status `"advisory"`, which does NOT cause the gate to fail. Result: a HIGH/CRITICAL CVE will be printed but `npm run preship` exits 0. The doc misleads operators into believing the gate blocks high-sev advisories — exactly the 3.0.42 CHANGELOG claim.
- Repro hypothesis: Add a fake HIGH advisory to `check-npm-audit.mjs` output path, then run `npm run preship` — exit code 0.
- Suggested next step: Either change `scripts/preship.mjs:57` to `mode: "hard"` (matching docs intent), or correct preship.md to "advisory only — does NOT block ship".
> **Resolved in v3.0.43 — preship gate hotfix (#130).** Verified: `scripts/preship.mjs` registers `npm-audit` as `mode: "hard"` and maps the check script's exit 1 (HIGH/CRITICAL) to `ok:false`; `docs/preship.md:30` documents "HIGH/CRITICAL block, MODERATE/LOW are advisory". Doc and code now agree.

#### DOCS-001 — README claims supervised deletion is capped at 5/hr; code caps at 20/hr
- Location: `README.md:319` (contradicted by `README.md:352`), `src/config/loader.ts:109-112`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: README:319 says `Deletion ... | full (capped at 5/hr in supervised)`. README:352 says "deletion 20/hr". The loader sets all deletion tools to `rateLimit = 20`. README is internally inconsistent and the 5/hr figure is wrong.
- Suggested next step: Change README:319 to "capped at 20/hr in `supervised`".
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** README:319 now reads "capped at 20/hr in `supervised`", matching `loader.ts` and README:352.

#### DOCS-002 — README claims shutdown_server/restart_server cap at 2/hr; code caps at 5/hr
- Location: `README.md:328`, `src/config/loader.ts:123-124`
- Category: Correctness
- Severity: Medium
- Confidence: Confirmed
- Description: README:328 says "shutdown_server and restart_server are capped at 2/hr in supervised". Loader sets both to 5. README:352 correctly says "server lifecycle 5/hr".
- Suggested next step: Fix README:328 to "5/hr".
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** README:328 now reads "capped at 5/hr in `supervised`", matching `loader.ts:125-126`.

#### DOCS-004 — HELP.md `fts_search` examples use `after:` arg that the schema does not accept
- Location: `HELP.md:163`, `src/tools/reading.ts` (`fts_search` schema)
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: HELP.md:163 shows `fts_search('"exact phrase" AND budget', after: "2025-01-01")`. The actual fields are `query`, `folder`, `sinceEpoch`, `limit`. A user copying the example will either get an MCP rejection or have the filter silently ignored.
- Suggested next step: Rewrite the example to use `sinceEpoch: <epoch-seconds>`.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** HELP.md:163 example now uses `sinceEpoch: 1735689600`, matching the `fts_search` schema.

#### DOCS-005 — HELP.md `remind_if_no_reply` example uses non-existent `emailId`/`days` arg names
- Location: `HELP.md:194`, `src/tools/drafts.ts:157-164`
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: HELP.md:194 shows `remind_if_no_reply(emailId: "...", days: 3, ...)`. The actual `inputSchema` has `email_id`, `after_days`. A user pasting the example gets two schema-validation failures.
- Suggested next step: Replace `emailId` with `email_id` and `days` with `after_days`.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** HELP.md:194 example now uses `email_id` / `after_days`, matching the `remind_if_no_reply` schema.

#### DOCS-006 — README_FIRST_AI.md documents non-existent `newTools` and wrong field names for escalation
- Location: `README_FIRST_AI.md:681-686`, `src/tools/escalation.ts:45-61`
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: Documents inputs `targetPreset` (camelCase) and `newTools` (string[]). The actual schema has `target_preset` (snake_case) and `reason`. There is no `newTools` input.
- Suggested next step: Replace with `target_preset`, delete `newTools` row, update return-shape sentence.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** README_FIRST_AI.md:680-688 now documents inputs `target_preset` + `reason` (both required), drops the bogus `newTools` input row, and corrects the return shape to `{ status: "pending", challenge_id, … }`. (`newTools` exists only in the *output* structuredContent, not as an input.)

#### DOCS-007 — HELP.md lists `.mailpouch-escalation-audit.jsonl` which never exists; mislabels `.mailpouch.audit.jsonl`
- Location: `HELP.md:275, 387-388`, `src/permissions/escalation.ts:124-128`
- Category: UX
- Severity: Medium
- Confidence: Confirmed
- Description: HELP.md:275 says "Escalation events are logged to `~/.mailpouch-escalation-audit.jsonl`" but the code writes to `~/.mailpouch.audit.jsonl`. HELP.md:387 labels the real path as "tool call audit log (hashed args)" — wrong.
- Suggested next step: Collapse to one line `~/.mailpouch.audit.jsonl  escalation audit log`. Drop the bogus path.
> **Resolved in v3.0.60 — UI/CSP + build/docs sweep.** HELP.md:275 now points to `~/.mailpouch.audit.jsonl`; the file list relabels it "escalation audit log" and drops the non-existent `~/.mailpouch-escalation-audit.jsonl`. (Confirmed `escalation.ts` is the sole writer of that path.)

#### DOCS-010 — HELP.md "Require destructive confirmation" omits `delete_folder`
- Location: `HELP.md:107`, `src/config/schema.ts:408-419`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: HELP.md:107 enumerates the tools that require `{ confirmed: true }` — `delete_folder` is missing despite being in `DESTRUCTIVE_TOOLS`.
- Suggested next step: Append `delete_folder` to the HELP.md enumeration.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** HELP.md:107 now lists `delete_folder` alongside the other `DESTRUCTIVE_TOOLS` entries.

#### DOCS-003 — README "System" category tool count is 4; schema has 5
- Location: `README.md:313`, `src/config/schema.ts:113-117`
- Category: Correctness
- Severity: Low
- Confidence: Confirmed
- Description: README table row 313 says `System | 4`. `TOOL_CATEGORIES.system.tools` is 5 tools. `get_server_version` was added in 3.0.34 but the row never followed.
- Suggested next step: Update README:313 from `4` to `5`.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** README System category row now reads `5`, matching `TOOL_CATEGORIES.system.tools`.

#### DOCS-008 — README env-var table omits five `MAILPOUCH_*` knobs the code reads
- Location: `README.md:291-300`, `src/index.ts:166-210`
- Category: UX
- Severity: Low
- Confidence: Confirmed
- Description: The README env-var table lists 8 vars. The code additionally reads `MAILPOUCH_REMINDERS`, `MAILPOUCH_PASS_AUDIT`, `MAILPOUCH_FTS_DB`, `MAILPOUCH_AGENTS`, `MAILPOUCH_AGENT_AUDIT`, `MAILPOUCH_MACHINE_SECRET`.
- Suggested next step: Extend the table with the six missing vars.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** The README env-var table now documents `MAILPOUCH_REMINDERS`, `MAILPOUCH_PASS_AUDIT`, `MAILPOUCH_FTS_DB`, `MAILPOUCH_AGENTS`, `MAILPOUCH_AGENT_AUDIT`, and `MAILPOUCH_MACHINE_SECRET` with their code defaults.

#### DOCS-011 — docs/index.md "System / Bridge" row omits `get_server_version`
- Location: `docs/index.md:37`, `src/config/schema.ts:116`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: Same omission as DOCS-003 — the index was never updated for the 3.0.34 addition.
- Suggested next step: Add `get_server_version` to the row.

> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `docs/index.md` "System / Bridge" row now includes `get_server_version`.

#### DOCS-012 — Schema's tier-comment internally inconsistent about core tool count
- Location: `src/config/schema.ts:372` vs `437`, `README.md:519`
- Category: Tech-debt
- Severity: Low
- Confidence: Confirmed
- Description: schema.ts:372 says core is `~26 tools, ~28 with escalation tools`. schema.ts:437 says core is `27 tools (reading 14 + sending 4 + analytics 4 + system 5) + 2 escalation = 29 visible`. The latter is correct.
- Suggested next step: Standardise on "core: 27 tools + 2 escalation = 29 visible" everywhere.
> **Resolved in v3.0.63 — transport/permission/UI/build/docs low-severity sweep.** `schema.ts:372` and README now both read "27 tools + 2 escalation = 29 visible", matching the authoritative comment at `schema.ts:477`.

**DOCS-MEMORY-DRIFT patterns:** tool-count and rate-limit drift dominate — `get_server_version` was added in 3.0.34 and the docs touched the headline "70 tools" but missed the System-category row, the docs/index Feature Map, and the schema's older tier comment. Rate-limit numbers in supervised/send_only get duplicated across three surfaces and the prose rows drift first because they're written in long-form English rather than copy-pasted from the loader. The `confirmed:true` story is also fragmented. The riskiest single finding is DOCS-009: preship.md and the 3.0.42 CHANGELOG entry both claim `npm-audit` blocks ship on HIGH/CRITICAL, but the orchestrator step is `mode: "advisory"`. Example commands in HELP.md (fts_search, remind_if_no_reply) use camelCase / English-y arg names that don't match the actual snake_case schemas.

---

## Methodology

Twelve parallel detective beats, three at a time, across four waves. Each beat read-only, scoped to a specific area of the repo, with a uniform finding format (location · category · severity · confidence · description · repro hypothesis · suggested next step). Severity rubric:

- **Critical** — active data loss / credential leak / auth bypass / RCE.
- **High** — user-visible correctness bug; can silently lose mail or corrupt state.
- **Medium** — defensive gap; exploitable or surprising edge case.
- **Low** — quality issue; not currently exploitable.
- **Info** — observation; not a bug.

Confidence: **Confirmed** (traced and reproduced static evidence), **Likely** (strong static evidence, not executed), **Suspect** (smelly pattern; needs deeper investigation).

### Out of scope

- Anything under `dist/` (build output).
- Anything under `node_modules/`.
- The 8 known-failing tests in `test/agent-harness.test.ts` (require live Bridge — documented).
- The v3.0.41 bugs A/B/C (already shipped in `007f0ec`).
- Compiled Rust under `native/tray/src/` — JS shim and napi build config reviewed, Rust source not analyzed.
- External-service supply-chain analysis beyond `npm audit` already gated by preship.
- Network-level probing of the OAuth tunnel (suspect patterns flagged; not executed).

### How to triage

Skim **Critical** + **High** first. Batch-fix **Medium** if they share a theme. Defer **Low** + **Info** unless they line up with a planned refactor. Findings tagged `Confidence: Suspect` are smells, not claims — verify in 30 seconds via the file:line citation before opening a PR.

---
