Просмотр исходного кода

perf: add TTL cache for account and mailbox name resolution

Cache account list and per-account mailbox names with 60s TTL to
avoid redundant AppleScript roundtrips on every tool call. Measured
288x speedup on repeated listAccounts calls and 2.6x on multi-account
search. Cache is invalidated on mailbox create/delete/rename.
Robert Sweet 3 месяцев назад
Родитель
Сommit
c63200cb06
2 измененных файлов с 92 добавлено и 21 удалено
  1. 1 1
      package.json
  2. 91 20
      src/services/appleMailManager.ts

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "apple-mail-mcp",
-  "version": "1.1.0",
+  "version": "1.1.1",
   "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
   "type": "module",
   "main": "build/index.js",

+ 91 - 20
src/services/appleMailManager.ts

@@ -126,6 +126,57 @@ export class AppleMailManager {
    */
   private defaultAccount: string | null = null;
 
+  /**
+   * TTL cache for expensive AppleScript queries that rarely change.
+   * Caches account list and per-account mailbox names to avoid
+   * redundant AppleScript roundtrips on every tool call.
+   */
+  private cache = {
+    accounts: null as { data: Account[]; expiry: number } | null,
+    mailboxNames: new Map<string, { data: string[]; expiry: number }>(),
+  };
+
+  /** Cache TTL in milliseconds (60 seconds). */
+  private readonly CACHE_TTL_MS = 60_000;
+
+  /**
+   * Returns cached accounts or fetches fresh data if cache is expired/empty.
+   */
+  private getCachedAccounts(): Account[] {
+    const now = Date.now();
+    if (this.cache.accounts && now < this.cache.accounts.expiry) {
+      return this.cache.accounts.data;
+    }
+    const accounts = this.fetchAccounts();
+    this.cache.accounts = { data: accounts, expiry: now + this.CACHE_TTL_MS };
+    return accounts;
+  }
+
+  /**
+   * Returns cached mailbox names for an account, or fetches fresh.
+   * This caches only the name list used by resolveMailbox(), not the
+   * full Mailbox objects with counts (which change frequently).
+   */
+  private getCachedMailboxNames(account: string): string[] {
+    const now = Date.now();
+    const cached = this.cache.mailboxNames.get(account);
+    if (cached && now < cached.expiry) {
+      return cached.data;
+    }
+    const names = this.fetchMailboxNames(account);
+    this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
+    return names;
+  }
+
+  /**
+   * Invalidate all caches. Call after operations that change
+   * mailbox structure (create/delete/rename mailbox).
+   */
+  private invalidateCache(): void {
+    this.cache.accounts = null;
+    this.cache.mailboxNames.clear();
+  }
+
   /**
    * Resolves the account to use for an operation.
    * Queries Mail.app's configured default send account, then falls back
@@ -151,7 +202,7 @@ export class AppleMailManager {
       const emailMatch = senderOutput.match(/<([^>]+)>/);
       const defaultEmail = emailMatch ? emailMatch[1] : senderOutput;
 
-      const accounts = this.listAccounts();
+      const accounts = this.getCachedAccounts();
       const matchedAccount = accounts.find(
         (a) => a.email.toLowerCase() === defaultEmail.toLowerCase()
       );
@@ -162,7 +213,7 @@ export class AppleMailManager {
     }
 
     // Fall back to first available account
-    const accounts = this.listAccounts();
+    const accounts = this.getCachedAccounts();
     if (accounts.length > 0) {
       this.defaultAccount = accounts[0].name;
       return this.defaultAccount;
@@ -190,26 +241,11 @@ export class AppleMailManager {
    * @returns Actual mailbox name, or original if not found
    */
   private resolveMailbox(mailbox: string, account: string): string {
-    // Get actual mailbox names from the account
-    const script = buildAccountScopedScript(
-      account,
-      `
-      set mbNames to {}
-      repeat with mb in mailboxes
-        set end of mbNames to name of mb
-      end repeat
-      return mbNames
-    `
-    );
-
-    const result = executeAppleScript(script);
-    if (!result.success || !result.output) {
+    const actualMailboxes = this.getCachedMailboxNames(account);
+    if (actualMailboxes.length === 0) {
       return mailbox; // Fall back to original
     }
 
-    // Parse the mailbox names (AppleScript returns comma-separated list)
-    const actualMailboxes = result.output.split(", ").map((s) => s.trim());
-
     // 1. Try exact match
     if (actualMailboxes.includes(mailbox)) {
       return mailbox;
@@ -1226,6 +1262,7 @@ export class AppleMailManager {
       return false;
     }
 
+    this.invalidateCache();
     return true;
   }
 
@@ -1254,6 +1291,7 @@ export class AppleMailManager {
       return false;
     }
 
+    this.invalidateCache();
     return true;
   }
 
@@ -1296,6 +1334,7 @@ export class AppleMailManager {
       return false;
     }
 
+    this.invalidateCache();
     return true;
   }
 
@@ -1304,9 +1343,17 @@ export class AppleMailManager {
   // ===========================================================================
 
   /**
-   * List all mail accounts.
+   * List all mail accounts (uses cache).
    */
   listAccounts(): Account[] {
+    return this.getCachedAccounts();
+  }
+
+  /**
+   * Fetches account list directly from Mail.app via AppleScript.
+   * Used internally by the cache; prefer getCachedAccounts() or listAccounts().
+   */
+  private fetchAccounts(): Account[] {
     const script = buildAppLevelScript(`
       set accountList to {}
       repeat with acct in accounts
@@ -1349,6 +1396,30 @@ export class AppleMailManager {
     return accounts;
   }
 
+  /**
+   * Fetches mailbox names for an account directly from Mail.app.
+   * Used internally by the cache; prefer getCachedMailboxNames().
+   */
+  private fetchMailboxNames(account: string): string[] {
+    const script = buildAccountScopedScript(
+      account,
+      `
+      set mbNames to {}
+      repeat with mb in mailboxes
+        set end of mbNames to name of mb
+      end repeat
+      return mbNames
+    `
+    );
+
+    const result = executeAppleScript(script);
+    if (!result.success || !result.output) {
+      return [];
+    }
+
+    return result.output.split(", ").map((s) => s.trim());
+  }
+
   // ===========================================================================
   // Mail Rules
   // ===========================================================================