diff --git a/CHANGELOG.md b/CHANGELOG.md index e808b74..429c576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +## [Unreleased]- **`mcpb/manifest.json` version is no longer ownerless** ([#311](https://github.com/PsychQuant/che-apple-mail-mcp/issues/311)). The field froze at `2.7.2` for ~18 releases because neither `release.sh` nor any Makefile target referenced it — masked all along by a coincidence: `Server.swift`'s then-hardcoded handshake version had rotted to the same value, so the two independently-wrong sources agreed (PR #307's dynamic handshake is what exposed the drift). Now: the manifest is bumped to the current release (`2.25.0`); `release.sh` refuses to tag when the manifest disagrees with the release version (fail-closed, JSON-parsed rather than grepped); and a new `ManifestVersionTests` pins the manifest to the newest released CHANGELOG header so CI catches the drift between releases. Same belt-and-suspenders shape as #303's `AppVersion.current` guard, but built only on what exists on `main`. - **`batch_export_emails_markdown` no longer silently overwrites files whose names differ only by letter case** ([#313](https://github.com/PsychQuant/che-apple-mail-mcp/issues/313)). On APFS (case-insensitive, case-preserving — the macOS default) `Re--X.md` and `RE--X.md` are one directory entry, but the collision guard compared exact strings: the second export sailed past `uniquify()`, overwrote the first file, and the manifest reported **both** as `written` with distinct `written_path`s and `errors: 0` — silent P0 data loss, hit routinely by Exchange (`RE:`) ↔ Apple Mail (`Re:`) correspondence on both sides of one thread. Two defects, same root: `uniquify()`'s membership set compared original-case strings, and the cross-call seed inserted original-case names while its comment (and the skill doc echoing it) claimed the scan was case-insensitive — the `.lowercased()` there only ever filtered the `.md` extension. Both now use lowercased keys: the whole case-family shares one `-N` suffix sequence, while returned filenames keep the caller's original case. Covered at three levels (guard unit test, single-call, and the issue's literal cross-call reproduction), all asserting on file *content* — path-string assertions cannot see this failure shape, since the buggy manifest reported two distinct paths while the disk held one file. ### Fixed diff --git a/Tests/CheAppleMailMCPTests/ManifestVersionTests.swift b/Tests/CheAppleMailMCPTests/ManifestVersionTests.swift new file mode 100644 index 0000000..888f99b --- /dev/null +++ b/Tests/CheAppleMailMCPTests/ManifestVersionTests.swift @@ -0,0 +1,44 @@ +import XCTest + +/// #311 — mcpb/manifest.json's `version` had no owner in the release pipeline +/// and froze at 2.7.2 for ~18 releases, masked because Server.swift's +/// then-hardcoded handshake version had rotted to the same value. This pins +/// the manifest to the newest released CHANGELOG header — the same invariant +/// `scripts/release.sh` now enforces at tag time — so the drift is caught in +/// CI between releases, not discovered by a user reading the bundle. +final class ManifestVersionTests: XCTestCase { + + private func repoRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // CheAppleMailMCPTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // repo root + } + + private func newestChangelogVersion() throws -> String { + let text = try String(contentsOf: repoRoot().appendingPathComponent("CHANGELOG.md"), + encoding: .utf8) + for line in text.split(separator: "\n") { + let s = line.trimmingCharacters(in: .whitespaces) + guard s.hasPrefix("## [") else { continue } + let inside = String(s.dropFirst(4).prefix(while: { $0 != "]" })) + // Skip "[Unreleased]" — accept only x.y.z + let parts = inside.split(separator: ".") + if parts.count == 3, parts.allSatisfy({ Int($0) != nil }) { return inside } + } + XCTFail("no released ## [x.y.z] header found in CHANGELOG.md") + return "" + } + + func testManifestVersionMatchesNewestRelease() throws { + let data = try Data(contentsOf: repoRoot().appendingPathComponent("mcpb/manifest.json")) + let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let manifestVersion = try XCTUnwrap(obj["version"] as? String, + "mcpb/manifest.json must declare a version") + let newest = try newestChangelogVersion() + XCTAssertEqual(manifestVersion, newest, + "mcpb/manifest.json version ('\(manifestVersion)') must match the newest released " + + "CHANGELOG header ('\(newest)') — it froze at 2.7.2 for ~18 releases because " + + "nothing owned it (#311). Bump it alongside the CHANGELOG at release prep.") + } +} diff --git a/mcpb/manifest.json b/mcpb/manifest.json index f3eadb1..52fbc1f 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "che-apple-mail-mcp", "display_name": "Apple Mail Manager", - "version": "2.7.2", + "version": "2.25.0", "description": "Apple Mail MCP server with 53 tools — SQLite-powered millisecond search across 250K+ emails", "long_description": "The most comprehensive Apple Mail MCP server providing 53 tools. v2.0 adds a SQLite search engine that queries Mail.app's Envelope Index directly for millisecond-speed search across all accounts (subject, sender, recipient, date range). Also includes .emlx file parser for direct email content reading, and batch operations (get_emails_batch, list_attachments_batch). Falls back to AppleScript when SQLite is unavailable. Requires macOS 13.0+ and Full Disk Access for SQLite features.", "author": { @@ -16,9 +16,18 @@ "homepage": "https://github.com/kiki830621/che-apple-mail-mcp", "support": "https://github.com/kiki830621/che-apple-mail-mcp/issues", "icon": "icon.png", - "keywords": ["apple-mail", "email", "mail", "macos", "applescript", "productivity"], + "keywords": [ + "apple-mail", + "email", + "mail", + "macos", + "applescript", + "productivity" + ], "license": "MIT", - "privacy_policies": ["https://github.com/kiki830621/che-apple-mail-mcp/blob/main/mcpb/PRIVACY.md"], + "privacy_policies": [ + "https://github.com/kiki830621/che-apple-mail-mcp/blob/main/mcpb/PRIVACY.md" + ], "server": { "type": "binary", "entry_point": "server/CheAppleMailMCP", @@ -29,55 +38,198 @@ } }, "tools": [ - { "name": "list_accounts", "description": "List all mail accounts" }, - { "name": "get_account_info", "description": "Get account details" }, - { "name": "list_mailboxes", "description": "List all mailboxes (folders)" }, - { "name": "create_mailbox", "description": "Create a new mailbox" }, - { "name": "delete_mailbox", "description": "Delete a mailbox" }, - { "name": "get_special_mailboxes", "description": "Get special mailbox names" }, - { "name": "list_emails", "description": "List emails in a mailbox" }, - { "name": "get_email", "description": "Get full email content" }, - { "name": "search_emails", "description": "SQLite-powered search (subject/sender/recipient/date range)" }, - { "name": "get_unread_count", "description": "Get unread count" }, - { "name": "get_email_headers", "description": "Get all email headers" }, - { "name": "get_email_source", "description": "Get raw email source" }, - { "name": "get_email_metadata", "description": "Get metadata (forwarded, replied, size)" }, - { "name": "mark_read", "description": "Mark as read/unread" }, - { "name": "flag_email", "description": "Flag/unflag email" }, - { "name": "set_flag_color", "description": "Set flag color (7 colors)" }, - { "name": "set_background_color", "description": "Set email background color" }, - { "name": "mark_as_junk", "description": "Mark as junk/not junk" }, - { "name": "move_email", "description": "Move to another mailbox" }, - { "name": "copy_email", "description": "Copy to another mailbox" }, - { "name": "delete_email", "description": "Delete email (to trash)" }, - { "name": "compose_email", "description": "Send new email" }, - { "name": "reply_email", "description": "Reply to email" }, - { "name": "forward_email", "description": "Forward email" }, - { "name": "redirect_email", "description": "Redirect email (keeps original sender)" }, - { "name": "open_mailto", "description": "Open mailto URL" }, - { "name": "list_drafts", "description": "List draft emails" }, - { "name": "create_draft", "description": "Create a draft" }, - { "name": "list_attachments", "description": "List email attachments" }, - { "name": "save_attachment", "description": "Save attachment to disk" }, - { "name": "list_vip_senders", "description": "List VIP senders" }, - { "name": "list_rules", "description": "List mail rules" }, - { "name": "get_rule_details", "description": "Get rule details" }, - { "name": "create_rule", "description": "Create a new rule" }, - { "name": "delete_rule", "description": "Delete a rule" }, - { "name": "enable_rule", "description": "Enable/disable a rule" }, - { "name": "list_signatures", "description": "List email signatures" }, - { "name": "get_signature", "description": "Get signature content" }, - { "name": "list_smtp_servers", "description": "List SMTP servers" }, - { "name": "check_for_new_mail", "description": "Check for new mail" }, - { "name": "synchronize_account", "description": "Sync IMAP account" }, - { "name": "extract_name_from_address", "description": "Extract name from email address" }, - { "name": "extract_address", "description": "Extract email from full address" }, - { "name": "get_mail_app_info", "description": "Get Mail.app info" }, - { "name": "import_mailbox", "description": "Import mailbox from file" }, - { "name": "get_emails_batch", "description": "Get multiple emails in one call" }, - { "name": "list_attachments_batch", "description": "List attachments for multiple emails" } + { + "name": "list_accounts", + "description": "List all mail accounts" + }, + { + "name": "get_account_info", + "description": "Get account details" + }, + { + "name": "list_mailboxes", + "description": "List all mailboxes (folders)" + }, + { + "name": "create_mailbox", + "description": "Create a new mailbox" + }, + { + "name": "delete_mailbox", + "description": "Delete a mailbox" + }, + { + "name": "get_special_mailboxes", + "description": "Get special mailbox names" + }, + { + "name": "list_emails", + "description": "List emails in a mailbox" + }, + { + "name": "get_email", + "description": "Get full email content" + }, + { + "name": "search_emails", + "description": "SQLite-powered search (subject/sender/recipient/date range)" + }, + { + "name": "get_unread_count", + "description": "Get unread count" + }, + { + "name": "get_email_headers", + "description": "Get all email headers" + }, + { + "name": "get_email_source", + "description": "Get raw email source" + }, + { + "name": "get_email_metadata", + "description": "Get metadata (forwarded, replied, size)" + }, + { + "name": "mark_read", + "description": "Mark as read/unread" + }, + { + "name": "flag_email", + "description": "Flag/unflag email" + }, + { + "name": "set_flag_color", + "description": "Set flag color (7 colors)" + }, + { + "name": "set_background_color", + "description": "Set email background color" + }, + { + "name": "mark_as_junk", + "description": "Mark as junk/not junk" + }, + { + "name": "move_email", + "description": "Move to another mailbox" + }, + { + "name": "copy_email", + "description": "Copy to another mailbox" + }, + { + "name": "delete_email", + "description": "Delete email (to trash)" + }, + { + "name": "compose_email", + "description": "Send new email" + }, + { + "name": "reply_email", + "description": "Reply to email" + }, + { + "name": "forward_email", + "description": "Forward email" + }, + { + "name": "redirect_email", + "description": "Redirect email (keeps original sender)" + }, + { + "name": "open_mailto", + "description": "Open mailto URL" + }, + { + "name": "list_drafts", + "description": "List draft emails" + }, + { + "name": "create_draft", + "description": "Create a draft" + }, + { + "name": "list_attachments", + "description": "List email attachments" + }, + { + "name": "save_attachment", + "description": "Save attachment to disk" + }, + { + "name": "list_vip_senders", + "description": "List VIP senders" + }, + { + "name": "list_rules", + "description": "List mail rules" + }, + { + "name": "get_rule_details", + "description": "Get rule details" + }, + { + "name": "create_rule", + "description": "Create a new rule" + }, + { + "name": "delete_rule", + "description": "Delete a rule" + }, + { + "name": "enable_rule", + "description": "Enable/disable a rule" + }, + { + "name": "list_signatures", + "description": "List email signatures" + }, + { + "name": "get_signature", + "description": "Get signature content" + }, + { + "name": "list_smtp_servers", + "description": "List SMTP servers" + }, + { + "name": "check_for_new_mail", + "description": "Check for new mail" + }, + { + "name": "synchronize_account", + "description": "Sync IMAP account" + }, + { + "name": "extract_name_from_address", + "description": "Extract name from email address" + }, + { + "name": "extract_address", + "description": "Extract email from full address" + }, + { + "name": "get_mail_app_info", + "description": "Get Mail.app info" + }, + { + "name": "import_mailbox", + "description": "Import mailbox from file" + }, + { + "name": "get_emails_batch", + "description": "Get multiple emails in one call" + }, + { + "name": "list_attachments_batch", + "description": "List attachments for multiple emails" + } ], "compatibility": { - "platforms": ["darwin"] + "platforms": [ + "darwin" + ] } } diff --git a/scripts/release.sh b/scripts/release.sh index bfc379e..127ad6f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -100,6 +100,21 @@ if ! grep -q "^## \[$VERSION_NO_V\]" CHANGELOG.md; then die "CHANGELOG.md has no entry for [$VERSION_NO_V]. add one before releasing." fi +# mcpb/manifest.json must agree with the tag (#311). The field had no owner in +# the release pipeline and froze at 2.7.2 for ~18 releases — masked because +# Server.swift's then-hardcoded handshake version had rotted to the same value. +# Fail-closed check (not auto-edit: this script requires a clean tree, so +# editing mid-release would contradict its own precondition). Parse with +# python3 json, not grep — the file is JSON, so read it as JSON. +MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('mcpb/manifest.json'))['version'])" 2>/dev/null || true) +if [[ -z "$MANIFEST_VERSION" ]]; then + die "could not read version from mcpb/manifest.json (missing file or invalid JSON)." +fi +if [[ "$MANIFEST_VERSION" != "$VERSION_NO_V" ]]; then + die "mcpb/manifest.json version is '$MANIFEST_VERSION' but releasing '$VERSION_NO_V'. + Bump it alongside the CHANGELOG entry (ManifestVersionTests pins the same invariant in CI)." +fi + info "Sanity checks passed." # ---- Extract release notes ---------------------------------------------------