Skip to content

fix: remove Cmd+A before HTML paste to preserve attachments#33

Closed
gerryke wants to merge 52 commits into
patrickfreyer:mainfrom
gerryke:fix/html-email-wipes-attachments
Closed

fix: remove Cmd+A before HTML paste to preserve attachments#33
gerryke wants to merge 52 commits into
patrickfreyer:mainfrom
gerryke:fix/html-email-wipes-attachments

Conversation

@gerryke
Copy link
Copy Markdown

@gerryke gerryke commented Apr 3, 2026

Problem

When calling compose_email with both body_html and attachments, the attachments are added to the compose window first, then the AppleScript does Cmd+A (select all) before pasting the HTML from clipboard.

Since Apple Mail embeds attachments inline in the message body, Cmd+A selects the attachments along with any body text. The subsequent Cmd+V paste then replaces everything — including the attachments — leaving the final email with HTML content but no attachments.

Root Cause

In _send_html_email, Step 4 of the AppleScript:

-- Select all in body and paste HTML
keystroke "a" using command down   ← this wipes the attachments
delay 0.2
keystroke "v" using command down

Fix

Remove the Cmd+A keystroke. The compose window body is initialized as empty (content:""), so there is nothing to clear before pasting. Attachments added in Step 2 are preserved.

Verification

Tested with compose_email(body_html=..., attachments=..., mode="open") — the resulting compose window now correctly shows both the rendered HTML content and the attachment side by side.

patrickfreyer and others added 30 commits February 9, 2026 23:19
…ar-mcp-personal

refactor: Split monolithic apple_mail_mcp.py into modular package
When send=False, the reply is saved as a draft instead of being sent
immediately. This allows users to review and edit replies before sending.

The reply is still created via Mail's `reply` command so it threads
properly with In-Reply-To/References headers, but instead of calling
`send`, the compose window is closed with `saving yes` which saves
to the Drafts folder.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
When saving a reply as draft (send=False), Mail.app's `close window
saving yes` saves the content property literally. But after `reply`,
the content property is empty (quoted text lives in the HTML layer).

For drafts, manually read the original message content and build the
quoted text so the saved draft includes both reply body and quoted
original. The send path is unaffected since `send` handles HTML quoting.

Also adds small delays after reply creation and content setting
to let Mail.app sync state before closing the window.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Email content from AppleScript can contain characters that break the
MCP stdio JSON-RPC transport, causing "CLI output was not valid JSON"
errors in Claude Desktop.

Changes:
- Add _sanitize_for_json() that normalizes line endings (\r → \n),
  forces ASCII-safe output, and strips control characters
- Switch run_applescript() to bytes mode with explicit UTF-8 decoding
  (errors='replace') instead of text=True which uses locale encoding
- Add __main__.py entry point to eliminate RuntimeWarning on stderr
  when running via `python -m apple_mail_mcp`

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…plescript-output-for-json

fix: sanitize AppleScript output to prevent JSON serialization errors
…draft

feat: add save-as-draft support to reply_to_email
Prevents AppleScript syntax errors when user input contains \n, \r,
or \t characters by escaping them in the output string.

Addresses issue patrickfreyer#19 (security audit item 1).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- empty_trash now requires confirm_empty=True and respects max_deletes
- manage_trash and update_email_status require at least one filter
  (subject_keyword or sender) or explicit apply_to_all=True
- save_email_attachment validates paths: must be under home dir,
  blocks writes to ~/.ssh, ~/.aws, ~/Library/LaunchAgents, etc.

Addresses issue patrickfreyer#19 (security audit items 2, 3, 4).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Adds max_emails parameter (default: 1000) to export_emails to prevent
unbounded exports when scope="entire_mailbox".

Addresses issue patrickfreyer#19 (security audit item 5).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Pin fastmcp==3.1.0 and mcp-ui-server==1.0.0 instead of open-ended
>=0.1.0 ranges to ensure reproducible builds.

Addresses issue patrickfreyer#19 (security audit item 6).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ssues-19-v2

fix: Address security issues from audit (patrickfreyer#19)
…atrickfreyer#10)

Add build_mailbox_ref(), build_filter_condition(), build_date_filter(),
and build_email_fields_script() to core.py. Update manage.py to use
build_filter_condition and build_mailbox_ref, reducing duplicated
mailbox resolution and condition-building code.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add output_format="json" parameter to list_inbox_emails,
get_recent_emails, and search_emails. When set to "json", returns
structured email data as a JSON array instead of formatted text,
making it easier for LLMs and downstream tools to parse results.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…atrickfreyer#8, patrickfreyer#11, patrickfreyer#12)

- create_mailbox: create new mailboxes with nested path support and
  name validation (patrickfreyer#8)
- search_emails_advanced: unified search with filters for subject,
  body, sender, dates, read/flagged/attachment status (patrickfreyer#11)
- archive_emails: safely move matching emails to Archive with
  dry_run default, filter requirement, and max_archive cap (patrickfreyer#12)
- Update __init__.py tool counts

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add three new MCP tools for batch email management:
- mark_emails: batch mark as read/unread/flagged/unflagged with filters
- delete_emails: soft-delete with dry_run=True default and safety limits
- bulk_move_emails: batch move with nested mailbox support

All tools require at least one filter and enforce max_emails safety
limits to prevent accidental mass operations.

Closes patrickfreyer#2, closes patrickfreyer#3, closes patrickfreyer#4

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…n, and sender analytics

Add three new tools in smart_inbox.py module:
- get_awaiting_reply (patrickfreyer#5): finds sent emails without replies by cross-referencing Sent and Inbox
- get_needs_response (patrickfreyer#6): identifies unread emails needing action, filtering out newsletters/automated
- get_top_senders (patrickfreyer#7): ranks most frequent senders with optional domain grouping

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Port attachment functionality from PR patrickfreyer#15 with security hardening:
- Add `attachments` parameter (comma-separated file paths) to both functions
- Validate paths: expand tilde, resolve symlinks, require home dir, block
  sensitive directories (.ssh, .gnupg, .aws, .config, .claude, Keychains, etc.)
- Check file existence with os.path.isfile() before passing to AppleScript
- Use consistent POSIX file syntax and delay 1 in attachment loops
- Return early with descriptive error if any path fails validation
- Extract shared validation into _validate_attachment_paths() helper

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…t-support

feat: add attachment support with security validation
…tering

Port performance improvements from PR patrickfreyer#13 into the modular codebase.
Five tools now use whose clause filtering at the Mail.app level instead
of iterating every message in AppleScript loops:

- search_emails: whose clause for subject, sender, read status, and
  programmatic date objects (locale-independent)
- search_by_sender: whose clause for sender + date, remove lowercase() handler
- get_recent_from_sender: whose clause for sender + date, remove lowercase()
- get_newsletters: whose date received pre-filter to avoid full mailbox scan
- get_statistics: whose clause pre-filtering for account_overview and
  sender_stats scopes, per-mailbox try/on error, skip system folders,
  division-by-zero guards

has_attachments kept as post-filter (not supported in whose clauses).
Newsletter pattern matching kept as post-filter (too complex for whose).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…use-perf

perf: whose clause filtering for 5 search/analytics tools
Adds a new delivery mode that opens emails in a visible Mail.app
compose window for user review before sending, instead of sending
immediately or saving silently to Drafts.

- compose_email: new `mode` param ("send"/"draft"/"open")
- reply_to_email: new `mode` param (overrides legacy `send` bool)
- manage_drafts: new "open" action to open existing drafts

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Open mode now lets Mail.app handle the quoted original natively
(with proper HTML formatting and blue quote bar) instead of
manually building plain-text quoted content.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
set content was replacing the entire message including the quoted
original. Now prepends the reply body to the existing content so
the native quoted original is preserved below.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Mail.app loads quoted original content asynchronously into the HTML
layer. Setting content via AppleScript overwrites it. Using keystroke
to type the reply preserves the native quoted original formatting.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Adds body_html parameter to compose_email for rich formatting (bold,
headings, links, colors). Uses AppleScriptObjC to place HTML on the
clipboard and paste into the compose window.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
patrickfreyer and others added 22 commits March 11, 2026 23:06
Reply body text was embedded directly into AppleScript source via f-string
interpolation, causing syntax errors when the body contained special
characters (em dashes, curly quotes, colons, slashes). Now writes the
body to a temp file and reads it in AppleScript via `do shell script`,
completely avoiding string escaping issues. Also switches open mode from
fragile per-char keystroke to clipboard paste, and adds Unicode line
separator handling to escape_applescript.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Body search was running as a post-filter, reading content of every
email in a loop — causing timeouts on large mailboxes. AppleScript's
contains operator is already case-insensitive, so body search can use
the native whose clause (content contains "...") for Mail.app-level
filtering, which is dramatically faster.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Manifest now reflects 27 tools (down from 35) after consolidating 9
search tools into one unified search_emails tool. Updated import comment.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The `reply` command with multiple `with` parameters requires `and`
between them. Without it, `reply_to_all: true` caused a syntax error
(-2741) for all delivery modes (send, draft, open).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
When enabled, removes compose_email, reply_to_email, and forward_email
from the tool registry. Draft management (list, create, delete) remains
available but the draft "send" action is blocked.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
feat: add --read-only flag to disable email sending tools
GitHub's markdown renderer blocks external SVG images. Using the
<picture> element with <source> tags is the recommended approach
for star-history.com charts in GitHub READMEs.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
export_emails() accepted any save_directory without validation, unlike
save_email_attachment() and compose attachment handling which already
enforce home-directory bounds and block sensitive dirs. This adds the
same path validation: realpath resolution, home-directory check, and
sensitive directory blocking.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…tured-search

feat: add rich draft workflow and structured mail search
The `_sanitize_for_json` function was encoding all text to ASCII with
`text.encode("ascii", "replace")`, replacing every non-ASCII character
with "?". This broke output for all non-Latin scripts: Cyrillic, CJK,
Arabic, Hebrew, etc.

Replace with a filter that strips control characters while preserving
all printable Unicode.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…tion-to-export-emails

Good security fix — consistent with existing path validation patterns in save_email_attachment.
Clean fix — ASCII restriction was overly cautious, UTF-8 works fine over stdio.
Release notes are maintained via GitHub Releases instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
reply_to_email (send/draft modes) and forward_email were using
`set content of` in AppleScript, which overwrites Mail.app's HTML layer
and destroys the quoted original / email thread history.

Now all modes use NSPasteboard HTML clipboard injection to paste the
reply body, preserving Mail's native quoted original below the reply.

Also adds body_html parameter to reply_to_email for rich-text replies
(HTML rendering still needs work — tracked in a separate issue).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…erves-email-history

fix: preserve email thread history in reply and forward
…il_thread)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…nread_count

- Add include_content param to list_inbox_emails (replaces get_recent_emails)
- Add summary_only param to get_mailbox_unread_counts (replaces get_unread_count)
- Remove get_recent_emails, _get_recent_emails_json, and get_unread_count
- Update inbox_dashboard in analytics.py to use get_mailbox_unread_counts(summary_only=True)
- Update __init__.py tool count comment (6 → 5)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…o manage.py

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When composing an HTML email with attachments, the AppleScript was
selecting all body content (Cmd+A) before pasting the HTML clipboard.
Since Mail embeds attachments inline in the body, this caused the
paste to overwrite and wipe out any attachments that had already been
added to the compose window.

Removing the select-all keystroke fixes the issue — the body starts
empty so there is nothing to clear, and attachments survive the paste.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@patrickfreyer
Copy link
Copy Markdown
Owner

Hi @gerryke — thanks for spotting this. The Cmd+A wipe is a real bug and the fix is correct.

Two issues with merging this PR as-is:

  1. Scope creep. The title says "remove Cmd+A before HTML paste" but the diff is 4,185 additions across 21 files — new smart_inbox.py, bulk.py, search rewrite, attachment support, security hardening, version bump, a docs/superpowers/plans/ directory, and a .claude/worktrees/agent-a88053b6 artifact that shouldn't be committed. This bundles many changes that each warrant focused review (same pattern that's stuck Fix: add localized inbox mailbox fallback for non-English systems #30 for a month).

  2. Repo layout moved. Since this PR was opened, the package was relocated from apple_mail_mcp/plugin/apple_mail_mcp/ (commit 573fbe2). Most of this diff would conflict.

Could you open a small focused PR with only the _send_html_email fix (the Cmd+A removal) against the current plugin/apple_mail_mcp/tools/compose.py? That's a ~5-line change I can review and merge quickly. The other improvements bundled here are interesting and welcome — but as separate PRs.

Happy to close this once the focused PR is up.

@patrickfreyer
Copy link
Copy Markdown
Owner

Closing — the headline 2-line fix is correct (I'll cherry-pick it directly), but this PR bundles a tool-consolidation refactor (4,185 LOC across 21 files), an accidentally-committed .claude/worktrees/agent-a88053b6 submodule pointer, and all files are at pre-plugin/ paths after the repo move. Cleaner to apply the Cmd+A removal in plugin/apple_mail_mcp/tools/compose.py:360-361 separately.

If you'd like to re-PR the tool-consolidation work as a focused change against the current plugin/ layout, happy to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants