--- name: applescript-mail description: Use when writing, modifying, or debugging ANY AppleScript that interacts with Apple Mail. Also use when encountering AppleScript errors, unexpected Mail.app behavior, when adding new connector methods, or when debugging JSON output from ASObjC. Covers string escaping, attachment handling, Gmail compatibility, message ID lookup patterns, NSJSONSerialization gotchas, and known Mail.app automation limitations. --- # AppleScript + Apple Mail Patterns ## Emitting JSON from AppleScript Scripts emit JSON via ASObjC + `NSJSONSerialization` (not pipe-delimited text). Use the `_wrap_as_json_script(body)` helper in `mail_connector.py` and parse responses with `parse_applescript_json(result)` from `utils.py`. **Tell-block contract:** the body passed to `_wrap_as_json_script` must contain a `tell application "Mail" ... end tell` block and assign the final value to a variable named `resultData` (list, record, or scalar). **Record key gotcha — always quote `name`:** Use `|name|:(name of acc)`, never `name:(name of acc)`. The bare form collides with NSObject's `name` selector and the key is silently dropped during NSDictionary conversion, leaving records without their name field. **`missing value` gotcha:** `NSJSONSerialization` rejects `missing value`. For optional properties (e.g., `email addresses` of an account with none, `unread count` on some mailboxes), coerce before building the record: ```applescript set accEmails to email addresses of acc if accEmails is missing value then set accEmails to {} ``` **Error propagation:** Do NOT wrap the tell-body in `try / on error` when you want typed exceptions (`MailAccountNotFoundError`, `MailMessageNotFoundError`, etc.). Let AppleScript errors bubble via stderr — `_run_applescript` maps them to the right exception type. ## Gmail Label-Based System Gmail doesn't support standard IMAP move operations. The `update_message` tool has a `gmail_mode` parameter (when used with `destination_mailbox`): ```python # Standard IMAP (Exchange, iCloud, etc.) move message to destination_mailbox # Gmail mode (copy + delete) duplicate message to destination_mailbox delete message # Removes from source label ``` **Bug story:** Early versions silently failed when moving Gmail messages. The move appeared to succeed but the message stayed in the source mailbox. `gmail_mode` was added to handle this. **When to use:** Always expose `gmail_mode` as an optional parameter on any tool that moves or archives messages. ## Message ID Lookup Finding a specific message by ID requires searching across accounts and mailboxes: ```applescript tell application "Mail" set allAccounts to every account repeat with acct in allAccounts set allMailboxes to every mailbox of acct repeat with mbox in allMailboxes set msgs to (messages of mbox whose id is targetId) if (count of msgs) > 0 then return first item of msgs end if end repeat end repeat end tell ``` **Performance:** This is O(accounts × mailboxes). For users with many accounts, this can be slow. The `whose` clause makes it tolerable but not fast. **Optimization:** If the caller knows the account and mailbox, always accept them as optional parameters to narrow the search. ## String Escaping **Always use `escape_applescript_string()` for user-provided text:** ```python # In utils.py — escapes backslashes first, then double quotes def escape_applescript_string(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"') ``` **Bug story:** Unescaped quotes in email subjects caused AppleScript blocks to fail silently. The error appears as a generic "Can't make" error in stderr with no indication of the actual cause. **Rule:** Every string interpolated into AppleScript MUST go through `escape_applescript_string()`. No exceptions. Check via `check_applescript_safety.sh`. ## Dates: never use a `date "..."` literal AppleScript parses date-string literals against the **user's locale**, and gets it wrong *silently*: ```applescript date "2026-05-28" --> year 12196 date "2026-08-09 11:03:51" --> Wednesday, October 8, 12177 ``` Neither raises. Nothing warns. A filter built on one of these just quietly matches nothing — and because the surrounding code behaves correctly, the bug is invisible above the AppleScript boundary. **Bug story (#436):** a date literal bounded a message-search window. The window was ~10,000 years off, so it matched nothing, and the failure surfaced as a clean, plausible `MailAnchorLookupIncompleteError` for a message sitting at **index 1 of the INBOX**. Cost a full integration cycle. The trap had already been documented — in a helper docstring inside a 6,000-line module, which is not where anyone writing new AppleScript looks. Hence this section. **Always build dates from components** via `_construct_as_date_var()` in `mail_connector.py`: ```python _construct_as_date_var("targetDate", 2026, 8, 9, 39831) # 39831 = 11:03:51 ``` which emits: ```applescript set targetDate to current date set day of targetDate to 1 -- FIRST: see below set year of targetDate to 2026 set month of targetDate to 8 set day of targetDate to 9 set time of targetDate to 39831 -- seconds since midnight ``` **Why `set day to 1` comes first:** if `current date` is the 31st and you set month to a 30-day month, AppleScript rolls into the next month. Resetting the day first makes the sequence safe for every date. **Rule:** every date in generated AppleScript goes through `_construct_as_date_var()`. `check_applescript_safety.sh` (Check 6) fails the build on a `date "..."` literal in `src/`. If you are *documenting* the trap rather than committing it, wrap the example in backticks — that is how the check distinguishes prose from code. ## Attachment Handling Attachments use POSIX file references: ```applescript -- Sending attachments set theAttachment to POSIX file "/Users/user/file.pdf" make new attachment with properties {file name: theAttachment} at after the last paragraph -- Saving attachments save attachment theAttach in POSIX file "/Users/user/Downloads/" ``` **Path conversion:** Python `Path` objects → `.as_posix()` → AppleScript `POSIX file "..."`. **Security:** Always validate: - File exists before sending - Directory exists before saving - No path traversal (`..` in path) - Extension not in blocklist (.exe, .bat, .sh, .app, etc.) - Size under 25MB limit ## `whose` Clause Filtering Use AppleScript `whose` clauses for server-side filtering instead of fetching all messages: ```applescript -- GOOD: Server-side filter (fast) set msgs to (messages of mbox whose sender contains "user@example.com") -- BAD: Fetch all then filter in Python (slow) set msgs to every message of mbox -- then filter in Python ``` **Combine clauses** for multi-field search: ```applescript messages whose sender contains "user" and subject contains "report" ``` **Limitation:** `whose` clauses don't support OR logic well. For OR conditions, use multiple `whose` queries and merge results in Python. ## Known Mail.app Automation Limitations 1. **No scheduled sending** — Mail.app has no AppleScript support for delayed/scheduled sends 2. **Thread reconstruction is possible but not native** — Mail.app has no `thread` or `conversation` class. Reconstruct threads by reading `headers of msg` for `in-reply-to`, `references`, and matching against `message id of msg` (the RFC 822 header value) across candidate messages. See `get_thread` in `mail_connector.py`. **`whose message id is "X"` is NOT indexed**, and AppleScript runs on Mail's UI thread — so it loads every message in the mailbox and *freezes the app*, not merely slowly (measured: 33,569 messages in one INBOX, 62,085 in Gmail's All Mail). The `_run_applescript` timeout does not rescue Mail; it kills our `osascript` client while Mail keeps grinding. Resolve an RFC Message-ID by **IMAP first** — `SEARCH HEADER Message-ID` is server-indexed (measured 0.14s over 61,880 messages) and returns the arrival date — then binary-search the mailbox by that date. Messages enumerate strictly newest-first with cheap positional access, so that is ~log₂(n) property reads. See `find_message_by_message_id` (#432/#434). Mail's numeric `id` *is* indexed, so `whose id is N` stays instant and needs none of this. 3. **Rule management is partial** — Rules are *readable* (`rules` collection, `name`, `enabled`, conditions/actions), but have no stable `id` and must be addressed positionally or by non-unique name. Mutation paths (creating, updating, deleting) are more complex and not yet implemented. 4. **No smart mailbox access** — Smart mailboxes are not exposed to AppleScript 5. **Rich text body** — `content of message` returns plain text; HTML body requires alternate approach 6. **Read receipt** — Cannot request or detect read receipts 7. **Draft management** — Creating drafts is possible but managing them is limited ## Error Handling Pattern ```applescript try -- operation return "result_data" on error errMsg return "ERROR: " & errMsg end try ``` **Python-side parsing:** ```python if result.startswith("ERROR:"): raise MailAppleScriptError(result[7:]) ``` **stderr-based errors** are caught in `_run_applescript()` and routed to typed exceptions: - `"Can't get account"` → `MailAccountNotFoundError` - `"Can't get mailbox"` → `MailMailboxNotFoundError` - `"Can't get message"` → `MailMessageNotFoundError` - Everything else → `MailAppleScriptError` ## Checklist: New AppleScript Operation 1. [ ] All user strings escaped with `escape_applescript_string()` 2. [ ] All inputs sanitized with `sanitize_input()` 3. [ ] Any dates built via `_construct_as_date_var()`, never a `date "..."` literal 4. [ ] Error handling with `try/on error` in AppleScript 5. [ ] Timeout considered (complex operations may need > 60s) 6. [ ] Integration test written against real Mail.app 7. [ ] `check_applescript_safety.sh` passes 8. [ ] Gmail compatibility considered (does this operation work with labels?)