Skip to content

fix(imap): dedupe accounts by wire identity; doctor's "connection failed: undefined" (#138); version-guard CHANGELOG hardening - #139

Merged
sweetrb merged 5 commits into
mainfrom
fix-imap-identity-dedupe
Aug 6, 2026
Merged

fix(imap): dedupe accounts by wire identity; doctor's "connection failed: undefined" (#138); version-guard CHANGELOG hardening#139
sweetrb merged 5 commits into
mainfrom
fix-imap-identity-dedupe

Conversation

@sweetrb

@sweetrb sweetrb commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Three changes. Two are user-visible IMAP bugs with a shared shape; the third hardens version-guard.yml and is applied byte-identically across all four repos.

Release: 2.10.7.


1. One mailbox declared twice was counted twice (get-unread-count, get-mail-stats)

listImapAccountSpecs() deduplicated accounts on the label:

if (specs.some((s) => s.accountLabel === label)) continue; // legacy wins

A config that declares the same mailbox through both the legacy singular keys (APPLE_MAIL_MCP_IMAP_USER + _IMAP_ACCOUNT) and an APPLE_MAIL_MCP_IMAP_ACCOUNTS entry carrying a different nickname walks straight past that comparison — the guard only catches the case where someone reuses the same name. Nothing downstream re-checks, so every caller that fans out over the account list visits the mailbox twice and sums it twice.

Measured live, on a four-identity config where two identities are one Gmail mailbox, both builds queried within seconds of each other:

shipped 2.10.6 this branch
get-unread-count (all accounts) 23 15
robert.b.sweet@gmail.com 8 8
Personal Gmail (same mailbox) 8 8
rob@superiortech.io 7 7
iCloud 0 0

23 = 8 + 8 + 7 + 0. The true total is 15. get-mail-stats inflated its message and unread totals the same way.

Fix

Dedupe on the mailbox's actual wire identity — the resolved (host, port, user) triple — via a new imapIdentityKey(). The connection pool's poolKey() now delegates to the same function, so the pool's notion of "the same account" and the enumerator's are literally one definition and cannot drift apart. The label check is kept as a secondary guard so two genuinely distinct mailboxes still can't share one nickname.

Normalisation is deliberately asymmetric: host is folded case-insensitively (DNS is), the user local part is not — RFC 5321 leaves it case-sensitive and only the receiving server may fold it, so folding it here could silently drop a real account. Double-counting is the lesser failure.

…and collapsing the duplicate does not break the nickname it collapsed

Worth calling out because the first cut of this change did regress: once Personal Gmail stopped being its own account, account: "Personal Gmail" no longer resolved and fell through to AppleScript, which reported Can't get account "Personal Gmail". Caught on live data, not by the unit tests.

The dropped entry's label is now retained as an alias of the surviving account, so a caller already passing that nickname keeps resolving to the same mailbox — it is simply no longer a second account. The three places that matched a selector against an account (the read-routing gate, the config resolver, the composite-id ownership check) were three copies of s.accountLabel === x || s.user === x; they now share one specMatchesSelector() predicate.


2. doctor reported connection failed: undefined for every account (#138)

Reported by @jarrah31, whose write-up isolates the guard, the call path and the reason the field is absent — the diagnosis below is his, confirmed independently before I read it.

imapHealthCheck's "is IMAP configured?" gate tested only the legacy singular variable:

if (!deps.config && !process.env[IMAP_ENV.user]?.trim()) {
  return { configured: false, ok: false };
}

So a config declaring its accounts solely through the documented multi-account array — no legacy keys at all — short-circuits to {configured: false, ok: false} carrying no error field, for every account, without ever attempting a connection. doctor interpolates that absent field bare, and the user gets the literal word undefined where the diagnosis should be. Every other IMAP path enumerates the array correctly, which is exactly why the tools work while only doctor disagrees.

Reproduced before fixing:

LABELS: ["Work"]  HEALTH: {"configured":false,"ok":false}
DOCTOR DETAIL: connection failed: undefined. Check the Keychain password and host/port.

Fix

The gate now asks the same enumerator every other caller asks:

if (!deps.config && listImapAccountSpecs().length === 0) {

@jarrah31 suggested !deps.account instead, which also fixes the reported symptom — doctor is currently the only caller and always passes a label. I went with the enumerator because it is the same question the rest of the module asks, and it stays correct if a future caller passes no account (under the !deps.account form, a bare imapHealthCheck() on an array-only config would still wrongly report unconfigured).

Two follow-ons in the same spirit:

  • doctor no longer interpolates the optional field bare — it falls back to words, so no future health-check failure can render undefined. There is a test asserting the rendered report never contains that string.
  • The IMAP not configured error now names APPLE_MAIL_MCP_IMAP_ACCOUNTS too, instead of pointing only at the legacy variable — the same asymmetry, in user-facing text.
  • docs/IMAP-SETUP.md, since @jarrah31 reasonably read the docs as saying array-only was unsupported ("the docs describe the legacy vars as defining the first/default account and the array as adding the rest"). The "Multiple accounts" section now states the array is sufficient on its own and that the first entry becomes the default; the env-var reference no longer claims only APPLE_MAIL_MCP_IMAP_USER enables IMAP. It also documents the rule fix 1 enforces: don't declare one mailbox twice.

Note both bugs are the same underlying mistake: treating the legacy singular keys as a privileged, separate identity rather than one row in the account list.


3. version-guard.yml: CHANGELOG history is append-only, and [Unreleased] must be empty on a bump

Two holes in the rule added in 2.10.5. Pure insertion: 70 lines added, 0 removed. Workflow name: version-guard, job key require-version-bump, and both triggers are untouched.

apple-notes-mcp, apple-numbers-mcp and apple-photos-mcp already merged this change; version-guard.yml is in conformance-check.sh's byte-identical set, and the file on this branch is now byte-identical to all three (sha256 be0df4ea…). (I initially also rewrote the file's header comment to describe all three CHANGELOG rules — a nicety the parent task marked optional. The siblings did not take it, so it would have broken the IDENTICAL set. Reverted; the last commit on this branch is that revert.)

Hole 1 — heading deletion. The existing rule proves only that the new version has a heading, which a PR can satisfy by renaming an existing one: retitling ## [1.1.12] to ## [1.1.13] leaves 1.1.13 documented and erases 1.1.12. apple-numbers-mcp #54 did exactly that, and since nothing downstream reads CHANGELOG.md it stayed invisible until an audit. Every ## [X.Y.Z] heading present at the base must now still be present.

Closed outside the bump branch, because a rename can land in a PR that bumps or one that does not. Compared against the checked-out tree, not git show HEAD_SHA:… — under pull_request the checkout is the merge commit, so a branch left open across a release already contains main's newer headings; reading HEAD_SHA would false-fail every such PR. Membership is tested with while read + grep -qxF rather than comm, matching conformance-check.sh's documented reasoning (no sort/locale assumptions; version strings are full of regex metacharacters).

Hole 2 — the [Unreleased] drain. The heading rule says nothing about notes still parked under ## [Unreleased], which the release drains: everything on main ships in the next publish, so prose left under that marker describes released behaviour while claiming to be unreleased, and nothing renames the section later. Nothing guarded this at all. Closed inside the bump branch. It first asserts the marker exists — dependabot-rebuild.yml hard-exits without it — then that the body is whitespace-only, printing the offending content indented. Both use index($0,"## [Unreleased]")==1 (conformance-check.sh's idiom, avoiding awk \[ portability questions); the terminator index($0,"## ")==1 correctly ignores ### Fixed sub-headings.

Edge cases

  • Shallow checkout — deepen via --unshallow when --is-shallow-repository, then hard-fail if the base commit is still unreachable rather than skipping. Skipping would be the same false-pass class conformance-check.sh's preflights exist to prevent. (In practice a truly depth-1 clone already dies one step earlier, at the pre-existing git diff BASE...HEAD; this is belt-and-braces.)
  • No CHANGELOG.md at base (first commit) — ::notice:: and continue. Distinguished from an unreachable base commit, which fails.
  • CHANGELOG.md deleted by the PR — explicit hard fail; it is the maximal heading deletion.
  • set -euo pipefail — every new pipeline is either an if/|| condition or ends in a command that cannot fail. No unguarded pipefail exits.

Accepted trade-off

Deliberately archiving old entries out of CHANGELOG.md now fails. Intentional and documented in the comment: the file is the only record of what each published version contains, and the guard cannot distinguish archival from the rename that erased 1.1.12. Second-order: if ## [Unreleased] on main ever became non-empty, every bumping PR would fail until the notes are filed — that is the drain being enforced, and it fails loudly. All four repos have an empty ## [Unreleased] on main today.


Verification

IMAP fixes

  • Both bugs reproduced with failing assertions before the fix (output quoted above).
  • Live A/B against three real accounts on the maintainer's Mac: shipped 2.10.6 reports 23 unread, this branch reports 15; per-account numbers sum correctly. doctor connects to imap.gmail.com ×2 and imap.mail.me.com — three , no undefined.
  • Live check that the collapsed nickname still routes (this is what caught the regression).
  • New unit tests: identity dedupe, mixed list, host-case folding, user-case not folded, port distinguishes, label collision still rejected, alias still addresses, imapHealthCheck configured-gate both ways, and doctor never rendering undefined.
  • test 436 passed / 31 files, typecheck, lint (0 errors; 10 pre-existing no-explicit-any warnings, none in touched files), format:check all clean.

version-guard

  1. Replayed apple-numbers-mcp Add Codex plugin marketplace alongside the Claude one #54 with the real base (8806992) and post-merge (8b0de21) CHANGELOGs → fails, naming 1.1.12. The hole is provably closed on the actual incident.
  2. All four repos' current main CHANGELOGs → heading extractor finds 74 / 63 / 21 / 24 headings, ## [Unreleased] present and empty in all four. No false positives; no repo is blocked.
  3. 12 synthetic scenarios, all correct: happy path; Add Codex plugin marketplace alongside the Claude one #54 replay with a bump; Add Codex plugin marketplace alongside the Claude one #54 shape without a bump; notes parked under [Unreleased] + bump (fails); same content with no bump (passes — correctly scoped); marker deleted (fails); whitespace-only [Unreleased] (passes); date-only heading edit with the version preserved (passes); CHANGELOG deleted (fails); first commit with no base CHANGELOG (passes); and both pre-existing rules still behaving (no-change passes, shipped-bytes-without-bump fails).
  4. Dependabot is unaffected — ran dependabot-rebuild.yml's exact auto-bump node snippet against all four real CHANGELOGs, then applied all three rules to each result: PASS ×4, including photos' leading # Changelog title line.
  5. Shallow-clone deepening exercised against a real shallow clone.
  6. Self-test: the patched block run against this very PRpreserves every release heading, documents 2.10.7, '## [Unreleased]' is empty, Shipped bytes changed and version is bumped — OK, exit 0.
  7. YAML parses (PyYAML), bash -n clean, prettier --check clean, diff vs main is a pure insertion (70 0).
  8. Byte-identity re-checked against the three siblings' merged main — all four hash to be0df4ea…, so conformance-check.sh's IDENTICAL set holds once this merges.

Notes for the maintainer

  • version-guard.yml byte-identity is verified, not assumed. The other three repos merged this change first; apple-mail-mcp was the odd one out (180 lines vs their 250). This branch brings it to 250 lines, sha256 be0df4ea69af02461606da9fcf19390b81b9158bb14a5d9aa45f683451518681 — matching all three exactly, so conformance-check.sh passes on merge.
  • The duplicate keys should also come out of the local config.json. The code fix makes the double-count impossible, but ~/Library/Application Support/apple-mail-mcp/config.json still declares robert.b.sweet@gmail.com twice — the legacy APPLE_MAIL_MCP_IMAP_ACCOUNT / _IMAP_USER / _IMAP_KEYCHAIN_* block and the Personal Gmail entry in APPLE_MAIL_MCP_IMAP_ACCOUNTS are the same mailbox. Dropping either one is now cosmetic rather than load-bearing. Not touched by this PR.

Fixes #138.

sweetrb added 5 commits August 6, 2026 11:34
…led: undefined" (#138)

Two defects with one shape: the legacy singular IMAP env keys were treated as
a special identity separate from APPLE_MAIL_MCP_IMAP_ACCOUNTS.

1. listImapAccountSpecs deduped on the account LABEL, so declaring one mailbox
   twice — once via the legacy keys, once as an ACCOUNTS entry under a
   different nickname — yielded two specs. Every merge-across-accounts caller
   then visited that mailbox twice. Dedupe now keys on the resolved
   (host, port, user) triple via a new imapIdentityKey(), which poolKey()
   delegates to so the pool and the enumerator cannot drift. The collapsed
   nickname is kept as an alias so it still addresses the mailbox.

2. imapHealthCheck's "is IMAP configured?" gate tested only the legacy
   APPLE_MAIL_MCP_IMAP_USER, so an ACCOUNTS-only setup short-circuited to
   {configured:false, ok:false} with no error field — which doctor rendered
   as the literal "connection failed: undefined" for every account, without
   ever attempting a connection. It now asks the same enumerator every other
   caller asks, and doctor never interpolates a bare optional.

Also hardens version-guard.yml's CHANGELOG rules: release headings are now
append-only, and "## [Unreleased]" must be empty on a bump.
@sweetrb
sweetrb merged commit d8d0827 into main Aug 6, 2026
17 of 27 checks passed
@sweetrb
sweetrb deleted the fix-imap-identity-dedupe branch August 6, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

doctor reports "connection failed: undefined" for every account configured only via APPLE_MAIL_MCP_IMAP_ACCOUNTS

1 participant