Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions Tests/CheAppleMailMCPTests/ManifestVersionTests.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
254 changes: 203 additions & 51 deletions mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand All @@ -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"
]
}
}
15 changes: 15 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------
Expand Down