apple-mail-mcp 1.6.3 → 1.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -85,6 +85,28 @@ function errorResponse(message) {
85
85
  isError: true,
86
86
  };
87
87
  }
88
+ /**
89
+ * Render a partial-coverage warning from search/list diagnostics, so a caller
90
+ * never mistakes an incomplete scan for a confirmed "no matches" (#24/#29).
91
+ * Returns "" when coverage was complete.
92
+ */
93
+ function partialCoverageBlock(diagnostics) {
94
+ const notes = [];
95
+ if (diagnostics.timedOutAccounts.length > 0) {
96
+ notes.push(`timed out (no results) for account(s): ${diagnostics.timedOutAccounts.join(", ")}`);
97
+ }
98
+ if (diagnostics.skippedLargeMailboxes.length > 0) {
99
+ notes.push(`skipped mailbox(es) too large to scan via AppleScript: ${diagnostics.skippedLargeMailboxes.join(", ")} — scope with \`mailbox\` (+ a \`dateFrom\`/\`dateTo\` window for search) to reach them`);
100
+ }
101
+ if (diagnostics.notSearchedMailboxes.length > 0) {
102
+ notes.push(`could not finish scanning mailbox(es): ${diagnostics.notSearchedMailboxes.join(", ")}`);
103
+ }
104
+ if (notes.length === 0)
105
+ return "";
106
+ return `\n\n⚠️ Partial results — this is NOT a confirmed "no such mail":\n${notes
107
+ .map((n) => ` - ${n}`)
108
+ .join("\n")}`;
109
+ }
88
110
  /**
89
111
  * Serial execution gate for AppleScript-backed tool calls (issue #11).
90
112
  *
@@ -137,24 +159,7 @@ server.tool("search-messages", {
137
159
  limit: z.number().optional().describe("Maximum number of results (default: 50)"),
138
160
  }, withErrorHandling(({ query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged, }) => {
139
161
  const { messages, diagnostics } = mailManager.searchMessagesWithDiagnostics(query, mailbox, account, limit, dateFrom, dateTo, from, subject, isRead, isFlagged);
140
- // Build a coverage note when the search couldn't reach everything, so a
141
- // caller never mistakes an incomplete search for a confirmed "no matches"
142
- // (issue #24).
143
- const coverageNotes = [];
144
- if (diagnostics.timedOutAccounts.length > 0) {
145
- coverageNotes.push(`timed out (no results) for account(s): ${diagnostics.timedOutAccounts.join(", ")}`);
146
- }
147
- if (diagnostics.skippedLargeMailboxes.length > 0) {
148
- coverageNotes.push(`skipped mailbox(es) too large to search via AppleScript: ${diagnostics.skippedLargeMailboxes.join(", ")} — scope the search with \`mailbox\` + a \`dateFrom\`/\`dateTo\` window to target them`);
149
- }
150
- if (diagnostics.notSearchedMailboxes.length > 0) {
151
- coverageNotes.push(`could not finish searching mailbox(es): ${diagnostics.notSearchedMailboxes.join(", ")}`);
152
- }
153
- const coverageBlock = coverageNotes.length > 0
154
- ? `\n\n⚠️ Partial results — this is NOT a confirmed "no such mail":\n${coverageNotes
155
- .map((n) => ` - ${n}`)
156
- .join("\n")}`
157
- : "";
162
+ const coverageBlock = partialCoverageBlock(diagnostics);
158
163
  if (messages.length === 0) {
159
164
  const base = diagnostics.partial
160
165
  ? "No messages found in the portions that were searched."
@@ -192,14 +197,18 @@ server.tool("list-messages", {
192
197
  from: z.string().optional().describe("Filter by sender email address or name"),
193
198
  unreadOnly: z.boolean().optional().describe("Only show unread messages"),
194
199
  }, withErrorHandling(({ mailbox, account, limit = 50, offset = 0, from }) => {
195
- const messages = mailManager.listMessages(mailbox, account, limit, from, offset);
200
+ const { messages, diagnostics } = mailManager.listMessagesWithDiagnostics(mailbox, account, limit, from, offset);
201
+ const coverageBlock = partialCoverageBlock(diagnostics);
196
202
  if (messages.length === 0) {
197
- return successResponse("No messages found");
203
+ const base = diagnostics.partial
204
+ ? "No messages found in the portions that were listed."
205
+ : "No messages found";
206
+ return successResponse(`${base}${coverageBlock}`);
198
207
  }
199
208
  const messageList = messages
200
209
  .map((m) => ` - ID: ${m.id} | ${m.dateReceived.toLocaleDateString()} | ${m.subject} (from: ${m.sender})`)
201
210
  .join("\n");
202
- return successResponse(`Found ${messages.length} message(s):\n${messageList}`);
211
+ return successResponse(`Found ${messages.length} message(s):\n${messageList}${coverageBlock}`);
203
212
  }, "Error listing messages"));
204
213
  // --- send-email ---
205
214
  server.tool("send-email", {
@@ -21,11 +21,12 @@ import type { Message, MessageContent, Mailbox, Account, Attachment, HealthCheck
21
21
  export declare function mergeSearchDiagnostics(into: SearchDiagnostics, from: SearchDiagnostics): void;
22
22
  /**
23
23
  * Split a per-account search payload into its message-list portion and parsed
24
- * diagnostics. The AppleScript appends a trailer of the form:
24
+ * diagnostics. The AppleScript appends a trailer of the form (using the
25
+ * control-character separators defined above):
25
26
  *
26
- * <messages>|||DIAG|||timedOut=true|||F|||skipped=Foo (9000)|||M||||||F|||notSearched=Bar|||M|||
27
+ * <messages>{DIAG_MARKER}timedOut=true{DIAG_FIELD_SEP}skipped=Foo (9000){DIAG_ITEM_SEP}{DIAG_FIELD_SEP}notSearched=Bar{DIAG_ITEM_SEP}
27
28
  *
28
- * `skipped`/`notSearched` are `|||M|||`-separated mailbox names, each prefixed
29
+ * `skipped`/`notSearched` are DIAG_ITEM_SEP-separated mailbox names, each prefixed
29
30
  * with the account name on the way out so the aggregate result is unambiguous.
30
31
  *
31
32
  * Exported (pure, no Mail.app dependency) for unit testing — this is the logic
@@ -203,6 +204,19 @@ export declare class AppleMailManager {
203
204
  * @returns Array of messages
204
205
  */
205
206
  listMessages(mailbox?: string, account?: string, limit?: number, from?: string, offset?: number): Message[];
207
+ /**
208
+ * List messages, returning matches plus coverage diagnostics.
209
+ *
210
+ * Like `searchMessages`, the unscoped (all-mailboxes) path used to iterate
211
+ * `messages of mb` over every mailbox with a swallowing per-mailbox `try`,
212
+ * so a large IMAP/Gmail mailbox timed out and the method returned `[]` — a
213
+ * false "No messages found." This applies the same #24 discipline: skip
214
+ * mailboxes above the scan threshold (reported), enforce a per-account
215
+ * wall-clock budget, capture per-mailbox timeouts, and surface all of it as a
216
+ * partial result. (by-id lookups don't need this — `whose id is` is indexed
217
+ * and returns instantly even on a 44k-message mailbox.)
218
+ */
219
+ listMessagesWithDiagnostics(mailbox?: string, account?: string, limit?: number, from?: string, offset?: number): SearchResult;
206
220
  /**
207
221
  * Parse message list output from AppleScript.
208
222
  *
@@ -1 +1 @@
1
- {"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAQH,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,OAAO,EACP,OAAO,EACP,UAAU,EACV,iBAAiB,EACjB,SAAS,EAET,oBAAoB,EACpB,UAAU,EACV,qBAAqB,EACrB,QAAQ,EACR,OAAO,EACP,aAAa,EACb,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACb,MAAM,YAAY,CAAC;AAwCpB;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAK7F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,iBAAiB,CAAA;CAAE,CAsCrD;AAoFD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,GAAG,MAAM,CAWrE;AA2CD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAoB5E;AAED,qBAAa,gBAAgB;IAC3B;;OAEG;IACH,OAAO,CAAC,cAAc,CAAuB;IAE7C;;;;OAIG;IACH,OAAO,CAAC,KAAK,CAGX;IAEF,8CAA8C;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAU;IAEvC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAUzB;;;;OAIG;IACH,OAAO,CAAC,qBAAqB;IAW7B;;;OAGG;IACH,OAAO,CAAC,eAAe;IAKvB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAwCtB;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,cAAc;IAyCtB;;;;;;;;OAQG;IACH,cAAc,CACZ,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,SAAK,EACV,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,OAAO,EAChB,SAAS,CAAC,EAAE,OAAO,GAClB,OAAO,EAAE;IAeZ;;;;;;;;;;;;;;;;;OAiBG;IACH,6BAA6B,CAC3B,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,SAAK,EACV,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,OAAO,EAChB,SAAS,CAAC,EAAE,OAAO,GAClB,YAAY;IAgMf;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAMzB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI;IAyE1C;;OAEG;IACH,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAgDpD;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IA6BvC;;;;;;;OAOG;IACH,YAAY,CACV,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EAChB,KAAK,SAAK,EACV,IAAI,CAAC,EAAE,MAAM,EACb,MAAM,SAAI,GACT,OAAO,EAAE;IA8GZ;;;;;;;;;;OAUG;IACH,OAAO,CAAC,gBAAgB;IAoCxB;;;;;;;;;;OAUG;IAuBH,SAAS,CACP,EAAE,EAAE,MAAM,EAAE,EACZ,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,EAAE,CAAC,EAAE,MAAM,EAAE,EACb,GAAG,CAAC,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,MAAM,EAAE,GACrB,OAAO;IA0DV;;;;;;;;;;;;OAYG;IACH,eAAe,CACb,UAAU,EAAE,oBAAoB,EAAE,EAClC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,GAAE,MAAY,GACpB,iBAAiB,EAAE;IAgDtB;;;;;;;;;;OAUG;IACH,WAAW,CACT,EAAE,EAAE,MAAM,EAAE,EACZ,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,EAAE,CAAC,EAAE,MAAM,EAAE,EACb,GAAG,CAAC,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,MAAM,EAAE,GACrB,OAAO;IAwDV;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,UAAQ,EAAE,IAAI,UAAO,GAAG,OAAO;IAqChF;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,UAAO,GAAG,OAAO;IA2C7E;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAsBzB;;OAEG;IACH,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAY/B;;OAEG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAYjC;;OAEG;IACH,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAYhC;;OAEG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAYlC;;OAEG;IACH,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAYlC;;OAEG;IACH;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,mBAAmB;IAwD3B,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO;IAYnE;;;;;OAKG;IACH,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAoB,EAAE;IAe1D;;;;;;;OAOG;IACH,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,oBAAoB,EAAE;IAe3F;;OAEG;IACH,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAoB,EAAE;IAStD;;OAEG;IACH,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAoB,EAAE;IAaxD;;OAEG;IACH,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAoB,EAAE;IASxD;;OAEG;IACH,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAoB,EAAE;IAS1D;;;;OAIG;IACH,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,EAAE;IAiEzC;;;;OAIG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO;IAsF7E;;OAEG;IACH,aAAa,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,EAAE;IA2C1C;;OAEG;IACH,cAAc,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM;IA8B1D;;OAEG;IACH,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO;IAyBtD;;OAEG;IACH,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO;IA0BtD;;OAEG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO;IA4C1E;;OAEG;IACH,YAAY,IAAI,OAAO,EAAE;IAIzB;;;OAGG;IACH,OAAO,CAAC,aAAa;IA2CrB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAwBzB;;OAEG;IACH,SAAS,IAAI,QAAQ,EAAE;IAiCvB;;OAEG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;IA+B3D;;OAEG;IACH,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,EAAE;IA4DxC,OAAO,CAAC,SAAS,CAAyC;IAC1D,OAAO,CAAC,cAAc,CAAK;IAE3B;;OAEG;IACH,aAAa,IAAI,aAAa,EAAE;IAIhC;;OAEG;IACH,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI;IAI7C;;OAEG;IACH,YAAY,CACV,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,EAAE,CAAC,EAAE,MAAM,EAAE,EACb,EAAE,CAAC,EAAE,MAAM,EAAE,EACb,EAAE,CAAC,EAAE,MAAM,GACV,aAAa;IAOhB;;OAEG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAInC;;OAEG;IACH,WAAW,CACT,EAAE,EAAE,MAAM,EACV,SAAS,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAC5E,OAAO;IAkBV;;OAEG;IACH,WAAW,IAAI,iBAAiB;IA8EhC;;OAEG;IACH,YAAY,IAAI,SAAS;IA4CzB;;;;;;;OAOG;IACH,wBAAwB,IAAI,qBAAqB;IA6DjD;;;;;;;;;OASG;IACH,aAAa,IAAI,UAAU;CA+D5B"}
1
+ {"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"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"}
@@ -50,8 +50,26 @@ function getMailboxScanThreshold() {
50
50
  const SEARCH_ACCOUNT_BUDGET_SECONDS = 30;
51
51
  /** osascript process timeout for a per-account search (ms). */
52
52
  const SEARCH_ACCOUNT_TIMEOUT_MS = 45000;
53
- /** Separator between the message payload and the diagnostics trailer. */
54
- const DIAG_MARKER = "|||DIAG|||";
53
+ /**
54
+ * Result serialization separators (issue #30).
55
+ *
56
+ * AppleScript emits structured results as delimited strings that TS then splits.
57
+ * The original delimiters were printable triple-pipe tokens, so any field value
58
+ * that itself contained one — a subject, sender, attachment filename, or mailbox
59
+ * name with a triple-pipe in it — shifted every subsequent field and silently
60
+ * corrupted the parse. These are now ASCII control characters
61
+ * (Unit/Record/Group Separator) which cannot occur in mail field values, so the
62
+ * collision is structurally impossible. The same constant is used by the
63
+ * AppleScript emitter (interpolated into the script string) and the TS parser,
64
+ * so the two can never drift.
65
+ */
66
+ const FIELD_SEP = "\x1f"; // US — between fields within a record
67
+ const RECORD_SEP = "\x1e"; // RS — between records
68
+ const DIAG_MARKER = "\x1dDIAG\x1d"; // GS-wrapped — payload/diagnostics boundary
69
+ const DIAG_FIELD_SEP = "\x1dF\x1d"; // between diagnostics fields
70
+ const DIAG_ITEM_SEP = "\x1dM\x1d"; // between diagnostics list items
71
+ const CONTENT_MARKER = "\x1dCONTENT\x1d"; // subject/plain-text boundary
72
+ const HTML_MARKER = "\x1dHTML\x1d"; // plain-text/source boundary
55
73
  /**
56
74
  * Merge a per-account SearchDiagnostics into an aggregate (all-accounts) one.
57
75
  *
@@ -66,11 +84,12 @@ export function mergeSearchDiagnostics(into, from) {
66
84
  }
67
85
  /**
68
86
  * Split a per-account search payload into its message-list portion and parsed
69
- * diagnostics. The AppleScript appends a trailer of the form:
87
+ * diagnostics. The AppleScript appends a trailer of the form (using the
88
+ * control-character separators defined above):
70
89
  *
71
- * <messages>|||DIAG|||timedOut=true|||F|||skipped=Foo (9000)|||M||||||F|||notSearched=Bar|||M|||
90
+ * <messages>{DIAG_MARKER}timedOut=true{DIAG_FIELD_SEP}skipped=Foo (9000){DIAG_ITEM_SEP}{DIAG_FIELD_SEP}notSearched=Bar{DIAG_ITEM_SEP}
72
91
  *
73
- * `skipped`/`notSearched` are `|||M|||`-separated mailbox names, each prefixed
92
+ * `skipped`/`notSearched` are DIAG_ITEM_SEP-separated mailbox names, each prefixed
74
93
  * with the account name on the way out so the aggregate result is unambiguous.
75
94
  *
76
95
  * Exported (pure, no Mail.app dependency) for unit testing — this is the logic
@@ -87,13 +106,13 @@ export function splitSearchDiagnostics(output, account) {
87
106
  notSearchedMailboxes: [],
88
107
  };
89
108
  if (trailer) {
90
- const fields = trailer.split("|||F|||");
109
+ const fields = trailer.split(DIAG_FIELD_SEP);
91
110
  const getField = (key) => {
92
111
  const f = fields.find((x) => x.startsWith(`${key}=`));
93
112
  return f ? f.slice(key.length + 1) : "";
94
113
  };
95
114
  const splitList = (raw) => raw
96
- .split("|||M|||")
115
+ .split(DIAG_ITEM_SEP)
97
116
  .map((s) => s.trim())
98
117
  .filter((s) => s.length > 0);
99
118
  diagnostics.skippedLargeMailboxes = splitList(getField("skipped")).map((mb) => `${account} / ${mb}`);
@@ -521,17 +540,17 @@ export class AppleMailManager {
521
540
  set msgDateStr to ${AS_DATE_TO_STRING}
522
541
  set msgRead to read status of msg as string
523
542
  set msgFlagged to flagged status of msg as string
524
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
525
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged
543
+ if msgCount > 0 then set outputText to outputText & "${RECORD_SEP}"
544
+ set outputText to outputText & msgId & "${FIELD_SEP}" & msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDateStr & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged
526
545
  set msgCount to msgCount + 1
527
546
  ${dateFilter ? "end if" : ""}
528
547
  end try
529
548
  end repeat
530
549
  on error _errMsg number _errNum
531
550
  set _timedOut to true
532
- set _notSearched to "${escapeForAppleScript(targetMailbox)}|||M|||"
551
+ set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
533
552
  end try
534
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "|||F|||skipped=|||F|||notSearched=" & _notSearched
553
+ return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
535
554
  `;
536
555
  }
537
556
  else {
@@ -556,7 +575,7 @@ export class AppleMailManager {
556
575
  end try
557
576
  if ((current date) - _startedAt) > ${SEARCH_ACCOUNT_BUDGET_SECONDS} then
558
577
  set _timedOut to true
559
- set _notSearched to _notSearched & mbName & "|||M|||"
578
+ set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
560
579
  else
561
580
  set mbCount to 0
562
581
  try
@@ -564,7 +583,7 @@ export class AppleMailManager {
564
583
  end try
565
584
  if (${scanGuard}) then
566
585
  set _timedOut to true
567
- set _skipped to _skipped & mbName & " (" & (mbCount as string) & ")|||M|||"
586
+ set _skipped to _skipped & mbName & " (" & (mbCount as string) & ")${DIAG_ITEM_SEP}"
568
587
  else
569
588
  try
570
589
  set allMessages to messages of mb ${searchCondition}
@@ -581,8 +600,8 @@ export class AppleMailManager {
581
600
  set msgDateStr to ${AS_DATE_TO_STRING}
582
601
  set msgRead to read status of msg as string
583
602
  set msgFlagged to flagged status of msg as string
584
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
585
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged & "|||" & mbName
603
+ if msgCount > 0 then set outputText to outputText & "${RECORD_SEP}"
604
+ set outputText to outputText & msgId & "${FIELD_SEP}" & msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDateStr & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & mbName
586
605
  set msgCount to msgCount + 1
587
606
  ${dateFilter ? "end if" : ""}
588
607
  end if
@@ -590,12 +609,12 @@ export class AppleMailManager {
590
609
  end repeat
591
610
  on error _errMsg number _errNum
592
611
  set _timedOut to true
593
- set _notSearched to _notSearched & mbName & "|||M|||"
612
+ set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
594
613
  end try
595
614
  end if
596
615
  end if
597
616
  end repeat
598
- return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "|||F|||skipped=" & _skipped & "|||F|||notSearched=" & _notSearched
617
+ return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=" & _skipped & "${DIAG_FIELD_SEP}notSearched=" & _notSearched
599
618
  `;
600
619
  }
601
620
  const script = buildAccountScopedScript(targetAccount, searchCommand);
@@ -668,7 +687,7 @@ export class AppleMailManager {
668
687
  if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
669
688
  end try
670
689
  end if
671
- return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount & "|||" & hasAtt
690
+ return msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDate & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & msgJunk & "${FIELD_SEP}" & msgDeleted & "${FIELD_SEP}" & msgMailbox & "${FIELD_SEP}" & msgAccount & "${FIELD_SEP}" & hasAtt
672
691
  end if
673
692
  end try
674
693
  end repeat
@@ -683,7 +702,7 @@ export class AppleMailManager {
683
702
  console.error(`Failed to get message ${id}: ${result.error}`);
684
703
  return null;
685
704
  }
686
- const parts = result.output.split("|||");
705
+ const parts = result.output.split(FIELD_SEP);
687
706
  if (parts.length < 9)
688
707
  return null;
689
708
  return {
@@ -719,7 +738,7 @@ export class AppleMailManager {
719
738
  try
720
739
  set htmlContent to source of msg
721
740
  end try
722
- return msgSubject & "|||CONTENT|||" & msgContent & "|||HTML|||" & htmlContent
741
+ return msgSubject & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlContent
723
742
  end if
724
743
  end try
725
744
  end repeat
@@ -734,10 +753,10 @@ export class AppleMailManager {
734
753
  console.error(`Failed to get message content: ${result.error}`);
735
754
  return null;
736
755
  }
737
- const htmlSplit = result.output.split("|||HTML|||");
756
+ const htmlSplit = result.output.split(HTML_MARKER);
738
757
  const contentPart = htmlSplit[0];
739
758
  const htmlContent = htmlSplit.length > 1 ? htmlSplit[1] : undefined;
740
- const parts = contentPart.split("|||CONTENT|||");
759
+ const parts = contentPart.split(CONTENT_MARKER);
741
760
  if (parts.length < 2)
742
761
  return null;
743
762
  return {
@@ -790,107 +809,172 @@ export class AppleMailManager {
790
809
  * @returns Array of messages
791
810
  */
792
811
  listMessages(mailbox, account, limit = 50, from, offset = 0) {
793
- // If no account specified, list across all accounts
812
+ return this.listMessagesWithDiagnostics(mailbox, account, limit, from, offset).messages;
813
+ }
814
+ /**
815
+ * List messages, returning matches plus coverage diagnostics.
816
+ *
817
+ * Like `searchMessages`, the unscoped (all-mailboxes) path used to iterate
818
+ * `messages of mb` over every mailbox with a swallowing per-mailbox `try`,
819
+ * so a large IMAP/Gmail mailbox timed out and the method returned `[]` — a
820
+ * false "No messages found." This applies the same #24 discipline: skip
821
+ * mailboxes above the scan threshold (reported), enforce a per-account
822
+ * wall-clock budget, capture per-mailbox timeouts, and surface all of it as a
823
+ * partial result. (by-id lookups don't need this — `whose id is` is indexed
824
+ * and returns instantly even on a 44k-message mailbox.)
825
+ */
826
+ listMessagesWithDiagnostics(mailbox, account, limit = 50, from, offset = 0) {
827
+ // If no account specified, list across all accounts and merge diagnostics.
794
828
  if (!account) {
795
829
  const accounts = this.listAccounts();
796
830
  const allMessages = [];
831
+ const diagnostics = {
832
+ partial: false,
833
+ timedOutAccounts: [],
834
+ skippedLargeMailboxes: [],
835
+ notSearchedMailboxes: [],
836
+ };
797
837
  for (const acct of accounts) {
798
838
  if (allMessages.length >= limit)
799
839
  break;
800
840
  const remaining = limit - allMessages.length;
801
- const msgs = this.listMessages(mailbox, acct.name, remaining, from, offset);
802
- allMessages.push(...msgs);
841
+ const res = this.listMessagesWithDiagnostics(mailbox, acct.name, remaining, from, offset);
842
+ allMessages.push(...res.messages);
843
+ mergeSearchDiagnostics(diagnostics, res.diagnostics);
803
844
  }
804
- return allMessages.slice(0, limit);
845
+ return { messages: allMessages.slice(0, limit), diagnostics };
805
846
  }
806
847
  const targetAccount = this.resolveAccount(account);
807
848
  const safeFrom = from ? escapeForAppleScript(from) : "";
808
849
  const fromFilter = from ? `whose sender contains "${safeFrom}"` : "";
850
+ const scanThreshold = getMailboxScanThreshold();
809
851
  let listCommand;
810
852
  if (mailbox) {
811
- // List from a specific mailbox
853
+ // List from a specific mailbox. Caller-scoped, so no count-guard skip, but
854
+ // wrap the scan so a timeout is reported as partial, not a false empty.
812
855
  const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
813
856
  listCommand = `
814
857
  set outputText to ""
858
+ set _timedOut to false
859
+ set _notSearched to ""
815
860
  set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
816
861
  set msgCount to 0
817
862
  set skipped to 0
818
- repeat with msg in messages of theMailbox ${fromFilter}
819
- if msgCount >= ${limit} then exit repeat
820
- try
821
- if skipped < ${offset} then
822
- set skipped to skipped + 1
823
- else
824
- set msgId to id of msg as string
825
- set msgSubject to subject of msg
826
- set msgSender to sender of msg
827
- set d to date received of msg
828
- set msgDate to ${AS_DATE_TO_STRING}
829
- set msgRead to read status of msg as string
830
- set msgFlagged to flagged status of msg as string
831
- set msgHasAtt to "false"
832
- try
833
- if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
834
- end try
835
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
836
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgHasAtt
837
- set msgCount to msgCount + 1
838
- end if
839
- end try
840
- end repeat
841
- return outputText
863
+ try
864
+ repeat with msg in messages of theMailbox ${fromFilter}
865
+ if msgCount >= ${limit} then exit repeat
866
+ try
867
+ if skipped < ${offset} then
868
+ set skipped to skipped + 1
869
+ else
870
+ set msgId to id of msg as string
871
+ set msgSubject to subject of msg
872
+ set msgSender to sender of msg
873
+ set d to date received of msg
874
+ set msgDate to ${AS_DATE_TO_STRING}
875
+ set msgRead to read status of msg as string
876
+ set msgFlagged to flagged status of msg as string
877
+ set msgHasAtt to "false"
878
+ try
879
+ if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
880
+ end try
881
+ if msgCount > 0 then set outputText to outputText & "${RECORD_SEP}"
882
+ set outputText to outputText & msgId & "${FIELD_SEP}" & msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDate & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & msgHasAtt
883
+ set msgCount to msgCount + 1
884
+ end if
885
+ end try
886
+ end repeat
887
+ on error _errMsg number _errNum
888
+ set _timedOut to true
889
+ set _notSearched to "${escapeForAppleScript(targetMailbox)}${DIAG_ITEM_SEP}"
890
+ end try
891
+ return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=${DIAG_FIELD_SEP}notSearched=" & _notSearched
842
892
  `;
843
893
  }
844
894
  else {
845
- // List from ALL mailboxes — iterate every mailbox in the account, dedup by message ID
895
+ // List from ALL mailboxes — skip mailboxes over the scan threshold, enforce
896
+ // the per-account budget, capture per-mailbox timeouts; dedup by message ID.
897
+ const scanGuard = scanThreshold > 0 ? `mbCount > ${scanThreshold}` : "false";
846
898
  listCommand = `
847
899
  set outputText to ""
848
900
  set msgCount to 0
849
901
  set skipped to 0
850
902
  set seenIds to {}
903
+ set _timedOut to false
904
+ set _skipped to ""
905
+ set _notSearched to ""
906
+ set _startedAt to current date
851
907
  repeat with mb in mailboxes
852
908
  if msgCount >= ${limit} then exit repeat
909
+ set mbName to ""
853
910
  try
854
- repeat with msg in messages of mb ${fromFilter}
855
- if msgCount >= ${limit} then exit repeat
911
+ set mbName to name of mb
912
+ end try
913
+ if ((current date) - _startedAt) > ${SEARCH_ACCOUNT_BUDGET_SECONDS} then
914
+ set _timedOut to true
915
+ set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
916
+ else
917
+ set mbCount to 0
918
+ try
919
+ set mbCount to count of messages of mb
920
+ end try
921
+ if (${scanGuard}) then
922
+ set _timedOut to true
923
+ set _skipped to _skipped & mbName & " (" & (mbCount as string) & ")${DIAG_ITEM_SEP}"
924
+ else
856
925
  try
857
- set msgId to id of msg as string
858
- if seenIds does not contain msgId then
859
- set end of seenIds to msgId
860
- if skipped < ${offset} then
861
- set skipped to skipped + 1
862
- else
863
- set msgSubject to subject of msg
864
- set msgSender to sender of msg
865
- set d to date received of msg
866
- set msgDate to ${AS_DATE_TO_STRING}
867
- set msgRead to read status of msg as string
868
- set msgFlagged to flagged status of msg as string
869
- set msgHasAtt to "false"
870
- try
871
- if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
872
- end try
873
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
874
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb & "|||" & msgHasAtt
875
- set msgCount to msgCount + 1
876
- end if
877
- end if
926
+ repeat with msg in messages of mb ${fromFilter}
927
+ if msgCount >= ${limit} then exit repeat
928
+ try
929
+ set msgId to id of msg as string
930
+ if seenIds does not contain msgId then
931
+ set end of seenIds to msgId
932
+ if skipped < ${offset} then
933
+ set skipped to skipped + 1
934
+ else
935
+ set msgSubject to subject of msg
936
+ set msgSender to sender of msg
937
+ set d to date received of msg
938
+ set msgDate to ${AS_DATE_TO_STRING}
939
+ set msgRead to read status of msg as string
940
+ set msgFlagged to flagged status of msg as string
941
+ set msgHasAtt to "false"
942
+ try
943
+ if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
944
+ end try
945
+ if msgCount > 0 then set outputText to outputText & "${RECORD_SEP}"
946
+ set outputText to outputText & msgId & "${FIELD_SEP}" & msgSubject & "${FIELD_SEP}" & msgSender & "${FIELD_SEP}" & msgDate & "${FIELD_SEP}" & msgRead & "${FIELD_SEP}" & msgFlagged & "${FIELD_SEP}" & mbName & "${FIELD_SEP}" & msgHasAtt
947
+ set msgCount to msgCount + 1
948
+ end if
949
+ end if
950
+ end try
951
+ end repeat
952
+ on error _errMsg number _errNum
953
+ set _timedOut to true
954
+ set _notSearched to _notSearched & mbName & "${DIAG_ITEM_SEP}"
878
955
  end try
879
- end repeat
880
- end try
956
+ end if
957
+ end if
881
958
  end repeat
882
- return outputText
959
+ return outputText & "${DIAG_MARKER}timedOut=" & (_timedOut as string) & "${DIAG_FIELD_SEP}skipped=" & _skipped & "${DIAG_FIELD_SEP}notSearched=" & _notSearched
883
960
  `;
884
961
  }
885
962
  const script = buildAccountScopedScript(targetAccount, listCommand);
886
- const result = executeAppleScript(script, { timeoutMs: 60000 });
963
+ const result = executeAppleScript(script, { timeoutMs: SEARCH_ACCOUNT_TIMEOUT_MS });
887
964
  if (!result.success) {
888
- console.error(`Failed to list messages: ${result.error}`);
889
- return [];
965
+ // Whole-account failure surface as a timeout, not a false empty (#24/#29).
966
+ console.error(`Failed to list messages in "${targetAccount}": ${result.error}`);
967
+ return {
968
+ messages: [],
969
+ diagnostics: {
970
+ partial: true,
971
+ timedOutAccounts: [targetAccount],
972
+ skippedLargeMailboxes: [],
973
+ notSearchedMailboxes: [],
974
+ },
975
+ };
890
976
  }
891
- if (!result.output.trim())
892
- return [];
893
- return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
977
+ return this.parseSearchResult(result.output, mailbox || "INBOX", targetAccount);
894
978
  }
895
979
  /**
896
980
  * Parse message list output from AppleScript.
@@ -904,10 +988,10 @@ export class AppleMailManager {
904
988
  * limitation). Use getMessage or list-attachments for authoritative info.
905
989
  */
906
990
  parseMessageList(output, mailbox, account) {
907
- const items = output.split("|||ITEM|||");
991
+ const items = output.split(RECORD_SEP);
908
992
  const messages = [];
909
993
  for (const item of items) {
910
- const parts = item.split("|||");
994
+ const parts = item.split(FIELD_SEP);
911
995
  if (parts.length < 6)
912
996
  continue;
913
997
  let msgMailbox = mailbox;
@@ -1488,8 +1572,8 @@ export class AppleMailManager {
1488
1572
  set attName to name of att
1489
1573
  set attType to MIME type of att
1490
1574
  set attSize to file size of att as string
1491
- if attCount > 0 then set outputText to outputText & "|||ITEM|||"
1492
- set outputText to outputText & attName & "|||" & attType & "|||" & attSize
1575
+ if attCount > 0 then set outputText to outputText & "${RECORD_SEP}"
1576
+ set outputText to outputText & attName & "${FIELD_SEP}" & attType & "${FIELD_SEP}" & attSize
1493
1577
  set attCount to attCount + 1
1494
1578
  end repeat
1495
1579
  return outputText
@@ -1504,10 +1588,10 @@ export class AppleMailManager {
1504
1588
  `);
1505
1589
  const result = executeAppleScript(script, { timeoutMs: 60000 });
1506
1590
  if (result.success && result.output.trim()) {
1507
- const items = result.output.split("|||ITEM|||");
1591
+ const items = result.output.split(RECORD_SEP);
1508
1592
  const attachments = [];
1509
1593
  for (const item of items) {
1510
- const parts = item.split("|||");
1594
+ const parts = item.split(FIELD_SEP);
1511
1595
  if (parts.length < 3)
1512
1596
  continue;
1513
1597
  attachments.push({
@@ -1625,9 +1709,9 @@ export class AppleMailManager {
1625
1709
  set mbName to name of mb
1626
1710
  set mbUnread to unread count of mb
1627
1711
  set mbCount to count of messages of mb
1628
- set end of mailboxList to mbName & "|||" & mbUnread & "|||" & mbCount
1712
+ set end of mailboxList to mbName & "${FIELD_SEP}" & mbUnread & "${FIELD_SEP}" & mbCount
1629
1713
  end repeat
1630
- set AppleScript's text item delimiters to "|||ITEM|||"
1714
+ set AppleScript's text item delimiters to "${RECORD_SEP}"
1631
1715
  return mailboxList as text
1632
1716
  `;
1633
1717
  const script = buildAccountScopedScript(targetAccount, listCommand);
@@ -1638,10 +1722,10 @@ export class AppleMailManager {
1638
1722
  }
1639
1723
  if (!result.output.trim())
1640
1724
  return [];
1641
- const items = result.output.split("|||ITEM|||");
1725
+ const items = result.output.split(RECORD_SEP);
1642
1726
  const mailboxes = [];
1643
1727
  for (const item of items) {
1644
- const parts = item.split("|||");
1728
+ const parts = item.split(FIELD_SEP);
1645
1729
  if (parts.length < 3)
1646
1730
  continue;
1647
1731
  mailboxes.push({
@@ -1789,9 +1873,9 @@ export class AppleMailManager {
1789
1873
  if (count of acctEmail) > 0 then
1790
1874
  set emailStr to item 1 of acctEmail
1791
1875
  end if
1792
- set end of accountList to acctName & "|||" & emailStr & "|||" & acctEnabled
1876
+ set end of accountList to acctName & "${FIELD_SEP}" & emailStr & "${FIELD_SEP}" & acctEnabled
1793
1877
  end repeat
1794
- set AppleScript's text item delimiters to "|||ITEM|||"
1878
+ set AppleScript's text item delimiters to "${RECORD_SEP}"
1795
1879
  return accountList as text
1796
1880
  `);
1797
1881
  const result = executeAppleScript(script);
@@ -1801,10 +1885,10 @@ export class AppleMailManager {
1801
1885
  }
1802
1886
  if (!result.output.trim())
1803
1887
  return [];
1804
- const items = result.output.split("|||ITEM|||");
1888
+ const items = result.output.split(RECORD_SEP);
1805
1889
  const accounts = [];
1806
1890
  for (const item of items) {
1807
- const parts = item.split("|||");
1891
+ const parts = item.split(FIELD_SEP);
1808
1892
  if (parts.length < 3)
1809
1893
  continue;
1810
1894
  accounts.push({
@@ -1845,19 +1929,19 @@ export class AppleMailManager {
1845
1929
  repeat with r in rules
1846
1930
  set ruleName to name of r
1847
1931
  set ruleEnabled to enabled of r
1848
- set end of ruleList to ruleName & "|||" & (ruleEnabled as string)
1932
+ set end of ruleList to ruleName & "${FIELD_SEP}" & (ruleEnabled as string)
1849
1933
  end repeat
1850
- set AppleScript's text item delimiters to "|||ITEM|||"
1934
+ set AppleScript's text item delimiters to "${RECORD_SEP}"
1851
1935
  return ruleList as text
1852
1936
  `);
1853
1937
  const result = executeAppleScript(script);
1854
1938
  if (!result.success || !result.output.trim()) {
1855
1939
  return [];
1856
1940
  }
1857
- const items = result.output.split("|||ITEM|||");
1941
+ const items = result.output.split(RECORD_SEP);
1858
1942
  const rules = [];
1859
1943
  for (const item of items) {
1860
- const parts = item.split("|||");
1944
+ const parts = item.split(FIELD_SEP);
1861
1945
  if (parts.length < 2)
1862
1946
  continue;
1863
1947
  rules.push({
@@ -1922,11 +2006,11 @@ export class AppleMailManager {
1922
2006
  if pPhones is not "" then set pPhones to pPhones & ","
1923
2007
  set pPhones to pPhones & (value of ph)
1924
2008
  end repeat
1925
- set end of matchedContacts to pName & "|||" & pEmails & "|||" & pPhones
2009
+ set end of matchedContacts to pName & "${FIELD_SEP}" & pEmails & "${FIELD_SEP}" & pPhones
1926
2010
  end if
1927
2011
  end repeat
1928
2012
 
1929
- set AppleScript's text item delimiters to "|||ITEM|||"
2013
+ set AppleScript's text item delimiters to "${RECORD_SEP}"
1930
2014
  return matchedContacts as text
1931
2015
  end tell
1932
2016
  `;
@@ -1934,10 +2018,10 @@ export class AppleMailManager {
1934
2018
  if (!result.success || !result.output.trim()) {
1935
2019
  return [];
1936
2020
  }
1937
- const items = result.output.split("|||ITEM|||");
2021
+ const items = result.output.split(RECORD_SEP);
1938
2022
  const contacts = [];
1939
2023
  for (const item of items) {
1940
- const parts = item.split("|||");
2024
+ const parts = item.split(FIELD_SEP);
1941
2025
  if (parts.length < 3)
1942
2026
  continue;
1943
2027
  contacts.push({
@@ -2160,14 +2244,14 @@ export class AppleMailManager {
2160
2244
  end try
2161
2245
  end repeat
2162
2246
 
2163
- return (last24h as string) & "|||" & (last7d as string) & "|||" & (last30d as string)
2247
+ return (last24h as string) & "${FIELD_SEP}" & (last7d as string) & "${FIELD_SEP}" & (last30d as string)
2164
2248
  `);
2165
2249
  const result = executeAppleScript(script, { timeoutMs: 60000 });
2166
2250
  if (!result.success || !result.output.trim()) {
2167
2251
  console.error(`Failed to get recently received stats: ${result.error}`);
2168
2252
  return { last24h: 0, last7d: 0, last30d: 0 };
2169
2253
  }
2170
- const parts = result.output.split("|||");
2254
+ const parts = result.output.split(FIELD_SEP);
2171
2255
  if (parts.length < 3) {
2172
2256
  return { last24h: 0, last7d: 0, last30d: 0 };
2173
2257
  }
@@ -2211,7 +2295,7 @@ export class AppleMailManager {
2211
2295
  set totalMailboxes to totalMailboxes + (count of mailboxes of acct)
2212
2296
  end repeat
2213
2297
 
2214
- return "running|||" & accountCount & "|||" & totalMailboxes
2298
+ return "running${FIELD_SEP}" & accountCount & "${FIELD_SEP}" & totalMailboxes
2215
2299
  `);
2216
2300
  const result = executeAppleScript(script);
2217
2301
  if (!result.success) {
@@ -2233,7 +2317,7 @@ export class AppleMailManager {
2233
2317
  };
2234
2318
  }
2235
2319
  // Parse the response
2236
- const parts = result.output.split("|||");
2320
+ const parts = result.output.split(FIELD_SEP);
2237
2321
  const isRunning = parts[0] === "running";
2238
2322
  const accountCount = parseInt(parts[1]) || 0;
2239
2323
  // Mail.app is running with accounts configured - assume sync is active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude",
5
5
  "type": "module",
6
6
  "main": "build/index.js",