Pārlūkot izejas kodu

fix(search): locale-independent date filtering in search-messages

search-messages returned zero results whenever dateFrom/dateTo was set on
non-English system locales. The bounds were compiled into AppleScript as
date "May 30, 2026" string coercion, which Mail.app parses using the system
locale; on a non-English locale the English month name throws
"Invalid date and time (-30720)", and because the comparison runs inside the
per-message try block, the error was silently swallowed and every message was
skipped.

Build the comparison date from numeric components in a new locale-independent
buildAppleScriptDate() helper instead. A date-only dateTo is treated as
end-of-day so the upper bound includes messages received later that same day.

Verified against real Mail data (en_US): dateFrom/dateTo now filter correctly
and bounds are inclusive. Added unit tests asserting no date "..." coercion is
emitted and components are assigned numerically.

Fixes #15
Robert Sweet 3 nedēļas atpakaļ
vecāks
revīzija
140cc7c2ff

+ 1 - 1
.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "apple-mail",
-  "version": "1.5.3",
+  "version": "1.5.4",
   "description": "Manage Apple Mail through natural language - read, search, send, and organize emails (macOS only)",
   "author": {
     "name": "Rob Sweet",

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.5.4] - 2026-06-01
+
+### Fixed
+- **`search-messages` returned zero results whenever `dateFrom`/`dateTo` was set on non-English system locales** — the date bounds were compiled into AppleScript as `date "May 30, 2026"` string coercion, which Mail.app parses using the system locale. On a non-English locale (e.g. pt_PT) the English month name throws `Invalid date and time (-30720)`; because the comparison runs inside the per-message `try` block, the error was silently swallowed and every message was skipped, so date-filtered searches returned nothing even when matching messages existed. The comparison date is now built from numeric components (`set year/month/day/…`) in a new locale-independent `buildAppleScriptDate()` helper, so date filters work regardless of system locale. A date-only `dateTo` is now treated as end-of-day so the upper bound includes messages received later that same day. ([#15](https://github.com/sweetrb/apple-mail-mcp/issues/15))
+
 ## [1.5.3] - 2026-05-27
 
 ### Fixed

+ 17 - 0
build/services/appleMailManager.d.ts

@@ -13,6 +13,23 @@
  * @module services/appleMailManager
  */
 import type { Message, MessageContent, Mailbox, Account, Attachment, HealthCheckResult, MailStats, BatchOperationResult, SyncStatus, RecentlyReceivedStats, MailRule, Contact, EmailTemplate, SerialEmailRecipient, SerialEmailResult } from "../types.js";
+/**
+ * Emits AppleScript that builds a date into the variable `varName` from numeric
+ * components.
+ *
+ * This is locale-independent, unlike `date "May 30, 2026"` string coercion,
+ * which AppleScript parses using the system locale. On a non-English locale
+ * (e.g. pt_PT) the English month name throws "Invalid date and time (-30720)";
+ * because the comparison happens inside the per-message `try` in searchMessages,
+ * that error is swallowed and every message is skipped, so the search returns
+ * zero results even when matches exist. See issue #15.
+ *
+ * `day` is reset to 1 before assigning month/year so an existing day-of-month
+ * (e.g. 31) cannot overflow into the next month when the month is changed.
+ *
+ * Exported for unit testing.
+ */
+export declare function buildAppleScriptDate(varName: string, d: Date): string;
 /**
  * Manager class for Apple Mail operations.
  *

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
build/services/appleMailManager.d.ts.map


+ 46 - 8
build/services/appleMailManager.js

@@ -89,6 +89,34 @@ function parseAppleScriptDate(dateStr) {
     const parsed = new Date(normalized);
     return isNaN(parsed.getTime()) ? new Date() : parsed;
 }
+/**
+ * Emits AppleScript that builds a date into the variable `varName` from numeric
+ * components.
+ *
+ * This is locale-independent, unlike `date "May 30, 2026"` string coercion,
+ * which AppleScript parses using the system locale. On a non-English locale
+ * (e.g. pt_PT) the English month name throws "Invalid date and time (-30720)";
+ * because the comparison happens inside the per-message `try` in searchMessages,
+ * that error is swallowed and every message is skipped, so the search returns
+ * zero results even when matches exist. See issue #15.
+ *
+ * `day` is reset to 1 before assigning month/year so an existing day-of-month
+ * (e.g. 31) cannot overflow into the next month when the month is changed.
+ *
+ * Exported for unit testing.
+ */
+export function buildAppleScriptDate(varName, d) {
+    return [
+        `set ${varName} to current date`,
+        `set day of ${varName} to 1`,
+        `set year of ${varName} to ${d.getFullYear()}`,
+        `set month of ${varName} to ${d.getMonth() + 1}`,
+        `set day of ${varName} to ${d.getDate()}`,
+        `set hours of ${varName} to ${d.getHours()}`,
+        `set minutes of ${varName} to ${d.getMinutes()}`,
+        `set seconds of ${varName} to ${d.getSeconds()}`,
+    ].join("\n      ");
+}
 /**
  * Builds an AppleScript command scoped to a specific account.
  */
@@ -326,18 +354,28 @@ export class AppleMailManager {
         // are additional AND filters. Date filtering stays post-fetch below — `whose`
         // date comparisons are unreliable in Mail.app AppleScript. See buildSearchCondition.
         const searchCondition = buildSearchCondition({ query, from, subject, isRead, isFlagged });
-        // Build date filter AppleScript.
-        // Note: dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA (alphanumeric + safe
-        // punctuation only), so escapeForAppleScript() below is belt-and-suspenders — it won't
-        // alter valid date strings but guards against future schema changes.
+        // Build the date-bound comparison. The comparison dates are constructed in
+        // AppleScript from numeric components (see buildAppleScriptDate) and compared
+        // against `msgDate` (set per-message below) rather than coerced from a
+        // locale-formatted string — `date "May 30, 2026"` throws on non-English system
+        // locales, and that swallowed error silently zeroes out results. See issue #15.
+        // dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA as parseable dates.
+        let dateSetup = "";
         let dateFilter = "";
         if (dateFrom || dateTo) {
             const dateChecks = [];
             if (dateFrom) {
-                dateChecks.push(`date received of msg >= date "${escapeForAppleScript(dateFrom)}"`);
+                dateSetup += buildAppleScriptDate("_dateFrom", new Date(dateFrom)) + "\n      ";
+                dateChecks.push("msgDate >= _dateFrom");
             }
             if (dateTo) {
-                dateChecks.push(`date received of msg <= date "${escapeForAppleScript(dateTo)}"`);
+                const to = new Date(dateTo);
+                // A date-only upper bound (no time component) is treated as end-of-day so
+                // messages received later that same day are still included.
+                if (!/\d:\d/.test(dateTo))
+                    to.setHours(23, 59, 59, 0);
+                dateSetup += buildAppleScriptDate("_dateTo", to) + "\n      ";
+                dateChecks.push("msgDate <= _dateTo");
             }
             dateFilter = dateChecks.join(" and ");
         }
@@ -346,7 +384,7 @@ export class AppleMailManager {
             // Search a specific mailbox
             const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
             searchCommand = `
-      set outputText to ""
+      ${dateSetup}set outputText to ""
       set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
       set allMessages to messages of theMailbox ${searchCondition}
       set msgCount to 0
@@ -373,7 +411,7 @@ export class AppleMailManager {
         else {
             // Search ALL mailboxes — iterate every mailbox in the account, dedup by message ID
             searchCommand = `
-      set outputText to ""
+      ${dateSetup}set outputText to ""
       set msgCount to 0
       set seenIds to {}
       repeat with mb in mailboxes

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "apple-mail-mcp",
-  "version": "1.5.3",
+  "version": "1.5.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "apple-mail-mcp",
-      "version": "1.5.3",
+      "version": "1.5.4",
       "license": "MIT",
       "os": [
         "darwin"

+ 1 - 1
package.json

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

+ 46 - 8
src/services/appleMailManager.ts

@@ -120,6 +120,35 @@ function parseAppleScriptDate(dateStr: string): Date {
   return isNaN(parsed.getTime()) ? new Date() : parsed;
 }
 
+/**
+ * Emits AppleScript that builds a date into the variable `varName` from numeric
+ * components.
+ *
+ * This is locale-independent, unlike `date "May 30, 2026"` string coercion,
+ * which AppleScript parses using the system locale. On a non-English locale
+ * (e.g. pt_PT) the English month name throws "Invalid date and time (-30720)";
+ * because the comparison happens inside the per-message `try` in searchMessages,
+ * that error is swallowed and every message is skipped, so the search returns
+ * zero results even when matches exist. See issue #15.
+ *
+ * `day` is reset to 1 before assigning month/year so an existing day-of-month
+ * (e.g. 31) cannot overflow into the next month when the month is changed.
+ *
+ * Exported for unit testing.
+ */
+export function buildAppleScriptDate(varName: string, d: Date): string {
+  return [
+    `set ${varName} to current date`,
+    `set day of ${varName} to 1`,
+    `set year of ${varName} to ${d.getFullYear()}`,
+    `set month of ${varName} to ${d.getMonth() + 1}`,
+    `set day of ${varName} to ${d.getDate()}`,
+    `set hours of ${varName} to ${d.getHours()}`,
+    `set minutes of ${varName} to ${d.getMinutes()}`,
+    `set seconds of ${varName} to ${d.getSeconds()}`,
+  ].join("\n      ");
+}
+
 /**
  * Builds an AppleScript command scoped to a specific account.
  */
@@ -430,18 +459,27 @@ export class AppleMailManager {
     // date comparisons are unreliable in Mail.app AppleScript. See buildSearchCondition.
     const searchCondition = buildSearchCondition({ query, from, subject, isRead, isFlagged });
 
-    // Build date filter AppleScript.
-    // Note: dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA (alphanumeric + safe
-    // punctuation only), so escapeForAppleScript() below is belt-and-suspenders — it won't
-    // alter valid date strings but guards against future schema changes.
+    // Build the date-bound comparison. The comparison dates are constructed in
+    // AppleScript from numeric components (see buildAppleScriptDate) and compared
+    // against `msgDate` (set per-message below) rather than coerced from a
+    // locale-formatted string — `date "May 30, 2026"` throws on non-English system
+    // locales, and that swallowed error silently zeroes out results. See issue #15.
+    // dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA as parseable dates.
+    let dateSetup = "";
     let dateFilter = "";
     if (dateFrom || dateTo) {
       const dateChecks: string[] = [];
       if (dateFrom) {
-        dateChecks.push(`date received of msg >= date "${escapeForAppleScript(dateFrom)}"`);
+        dateSetup += buildAppleScriptDate("_dateFrom", new Date(dateFrom)) + "\n      ";
+        dateChecks.push("msgDate >= _dateFrom");
       }
       if (dateTo) {
-        dateChecks.push(`date received of msg <= date "${escapeForAppleScript(dateTo)}"`);
+        const to = new Date(dateTo);
+        // A date-only upper bound (no time component) is treated as end-of-day so
+        // messages received later that same day are still included.
+        if (!/\d:\d/.test(dateTo)) to.setHours(23, 59, 59, 0);
+        dateSetup += buildAppleScriptDate("_dateTo", to) + "\n      ";
+        dateChecks.push("msgDate <= _dateTo");
       }
       dateFilter = dateChecks.join(" and ");
     }
@@ -453,7 +491,7 @@ export class AppleMailManager {
       const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
 
       searchCommand = `
-      set outputText to ""
+      ${dateSetup}set outputText to ""
       set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
       set allMessages to messages of theMailbox ${searchCondition}
       set msgCount to 0
@@ -479,7 +517,7 @@ export class AppleMailManager {
     } else {
       // Search ALL mailboxes — iterate every mailbox in the account, dedup by message ID
       searchCommand = `
-      set outputText to ""
+      ${dateSetup}set outputText to ""
       set msgCount to 0
       set seenIds to {}
       repeat with mb in mailboxes

+ 32 - 0
src/services/appleScriptDate.test.ts

@@ -0,0 +1,32 @@
+import { describe, it, expect } from "vitest";
+import { buildAppleScriptDate } from "@/services/appleMailManager.js";
+
+describe("buildAppleScriptDate", () => {
+  // Regression for issue #15: the comparison date must be built from numeric
+  // components, never coerced from a locale-formatted string like
+  // `date "May 30, 2026"`, which throws on non-English system locales and
+  // silently zeroes out date-filtered searches.
+  it('never emits locale-dependent `date "..."` string coercion', () => {
+    const script = buildAppleScriptDate("_dateFrom", new Date(2026, 4, 30));
+    expect(script).not.toMatch(/date\s+"/);
+  });
+
+  it("assigns each component numerically with a 1-based month", () => {
+    const script = buildAppleScriptDate("_dateFrom", new Date(2026, 4, 30, 9, 15, 45));
+    expect(script).toContain("set _dateFrom to current date");
+    expect(script).toContain("set year of _dateFrom to 2026");
+    expect(script).toContain("set month of _dateFrom to 5"); // May -> 5, not 4
+    expect(script).toContain("set day of _dateFrom to 30");
+    expect(script).toContain("set hours of _dateFrom to 9");
+    expect(script).toContain("set minutes of _dateFrom to 15");
+    expect(script).toContain("set seconds of _dateFrom to 45");
+  });
+
+  it("resets day to 1 before setting month/year to avoid month overflow", () => {
+    const script = buildAppleScriptDate("_d", new Date(2026, 1, 28));
+    const firstDayReset = script.indexOf("set day of _d to 1");
+    const monthAssign = script.indexOf("set month of _d to 2");
+    expect(firstDayReset).toBeGreaterThanOrEqual(0);
+    expect(monthAssign).toBeGreaterThan(firstDayReset);
+  });
+});

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels