An Intelligrit Labs Project
A command-line interface for controlling macOS Mail.app. Provides complete scriptable access to accounts, mailboxes, messages, and attachments.
- List and manage Mail.app accounts
- Browse and manage mailboxes
- List, read, search, and manage messages
- Archive, move, delete, flag, and mark messages
- Send emails
- Manage attachments
- Fully scriptable - perfect for automation and building GUIs
- Emacs Frontend: Full-featured interactive Emacs mail client included in
emacs/
go install github.com/intelligrit/mail-app-cli@latestgit clone https://github.com/intelligrit/mail-app-cli.git
cd mail-app-cli
go build -o mail-app-cliList all Mail.app accounts:
mail-app-cli accounts listShow details for a specific account:
mail-app-cli accounts show "Gmail"List all mailboxes:
mail-app-cli mailboxes listList mailboxes for a specific account:
mail-app-cli mailboxes list --account "Gmail"Include per-mailbox message totals (TotalCount; slower, enumerates each mailbox):
mail-app-cli mailboxes list --countsList messages in a mailbox:
mail-app-cli messages list --account "Gmail" --mailbox "INBOX"List with filters:
# Show only unread messages
mail-app-cli messages list -a "Gmail" -m "INBOX" --unread
# Show only flagged messages
mail-app-cli messages list -a "Gmail" -m "INBOX" --flagged
# Show messages since a specific date
mail-app-cli messages list -a "Gmail" -m "INBOX" --since "2025-12-01"
# Show messages since a specific date and time
mail-app-cli messages list -a "Gmail" -m "INBOX" --since "2025-12-14 09:00:00"
# Combine filters
mail-app-cli messages list -a "Gmail" -m "INBOX" --unread --since "2025-12-01" --limit 10
--with-contentcaution: fetching bodies can block Mail.app's entire scripting interface if a body is not cached locally (see PERFORMANCE.md). Use it on small, recently synced mailboxes, not from unattended jobs.
Every message includes MessageID (the RFC 5322 Message-ID header) for
free. Add --with-headers to list and the unified subcommands to also get
InReplyTo and References, parsed from the message's raw headers, for
building a threaded view; it is opt-in because bulk-fetching headers is
roughly 9x slower than the other list fields:
mail-app-cli messages list -a "Gmail" -m "INBOX" --with-headersmessages show always includes InReplyTo/References (it already pays
the cost of a single-message fetch for Content).
Show full message details:
mail-app-cli messages show <message-id> -a "Gmail" -m "INBOX"Mark messages as read/unread (any number of IDs, one Mail.app round trip):
# Mark as read
mail-app-cli messages mark <message-id> [<message-id>...] -a "Gmail" -m "INBOX" --read
# Mark as unread
mail-app-cli messages mark <message-id> -a "Gmail" -m "INBOX" --read=falseFlag/unflag messages:
# Flag messages
mail-app-cli messages flag <message-id> [<message-id>...] -a "Gmail" -m "INBOX" --flagged
# Unflag a message
mail-app-cli messages flag <message-id> -a "Gmail" -m "INBOX" --flagged=falseArchive messages:
mail-app-cli messages archive <message-id> [<message-id>...] -a "Exchange" -m "Inbox"Gmail limitation: archiving is refused for Gmail accounts. Mail.app offers no safe scriptable archive for Gmail — scripted moves out of INBOX silently revert on the next sync, and the only workaround that sticks (bouncing the message through Trash) can permanently delete mail. Archive Gmail messages in Mail.app or Gmail itself, or use
messages deleteto send them to Trash.
Move messages to another mailbox (the last argument is the target):
mail-app-cli messages move <message-id> [<message-id>...] "Archive" -a "Gmail" -m "INBOX"Delete messages:
mail-app-cli messages delete <message-id> [<message-id>...] -a "Gmail" -m "INBOX"Deleting a message that is already in Trash removes it permanently.
All mutation commands accept multiple IDs and process them in a single Mail.app call.
Global IDs (no --account/--mailbox): Mail.app message IDs are unique
across all accounts, so mutations can also be run with just IDs — from any mix
of accounts and mailboxes in one call. The output is then a JSON summary with a
per-message status:
mail-app-cli messages archive 399357 399364 401002{
"results": [
{"id": "399357", "account": "Gmail", "mailbox": "All Mail", "status": "skipped", "gmail": true,
"error": "Gmail accounts cannot be archived via Mail.app scripting"},
{"id": "399364", "account": "Exchange", "mailbox": "Inbox", "status": "ok"},
{"id": "401002", "status": "missing"}
],
"ok": 1, "missing": 1, "failed": 0, "skipped": 1
}Statuses are ok, missing, failed (with error) and skipped. For
archive, --gmail skip|delete|read decides what happens to Gmail messages:
leave them and report skipped (default), move them to Trash, or mark them
read. move resolves the target mailbox inside each message's own account. Each ID is reported individually: a missing ID does not stop
the others, and the command exits non-zero listing the IDs that failed.
Note: when a message is moved (archive, move, delete) Mail.app assigns it a new ID in the destination mailbox. Re-list the destination if you need to act on it again.
Mark every message in a mailbox as read in one call:
mail-app-cli mailboxes mark-read -a "Gmail" -m "Spam"Or hit the provider-independent special mailboxes across all accounts. Mail.app resolves the names ("Trash" vs "Deleted Items", "Spam" vs "Junk Email") itself:
# Everything you never read anyway
mail-app-cli mailboxes mark-read --trash --junk --archive
mail-app-cli mailboxes mark-read --all # same thing
# Only some accounts (repeatable)
mail-app-cli mailboxes mark-read --all -a "Skyward" -a "Intelligrit"
# See what would change first
mail-app-cli mailboxes mark-read --all --dry-run
# Mark unread instead
mail-app-cli mailboxes mark-read -a "Gmail" -m "INBOX" --unreadOutput is a JSON array of {account, mailbox, changed} per mailbox touched.
--archive matches a mailbox literally named "Archive"; Gmail's "All Mail" is
skipped because it also holds every inbox message (pass -m "All Mail"
explicitly if you really want that).
Send a message:
mail-app-cli send \
--account "Gmail" \
--to user@example.com \
--subject "Hello" \
--body "Message content here"Send to multiple recipients:
mail-app-cli send \
-a "Gmail" \
-t user1@example.com \
-t user2@example.com \
-c cc@example.com \
-s "Multi-recipient message" \
--body "Content"Search for messages across all mailboxes:
mail-app-cli search "important meeting"Search with limit:
mail-app-cli search "project update" --limit 20List attachments in a message:
mail-app-cli attachments list <message-id> -a "Gmail" -m "INBOX"Save an attachment:
mail-app-cli attachments save <message-id> "document.pdf" -a "Gmail" -m "INBOX"Save to a specific path:
mail-app-cli attachments save <message-id> "document.pdf" -a "Gmail" -m "INBOX" -o ~/Downloads/document.pdfAll commands output JSON format for easy parsing and scripting. The output is formatted with 2-space indentation for human readability while remaining machine-parseable.
For even prettier output, pipe through jq:
mail-app-cli accounts list | jqmail-app-cli accounts list | jq '.[] | select(.EmailAddress | endswith("@gmail.com"))'mail-app-cli accounts list | jq '.[] | select(.Enabled==true) | .Name'mail-app-cli mailboxes list | jq '[.[].UnreadCount] | add'mail-app-cli mailboxes list | jq '.[] | select(.UnreadCount > 0) | {account: .Account, name: .Name, unread: .UnreadCount}'mail-app-cli messages list -a "Gmail" -m "INBOX" | jq '.[].Subject'mail-app-cli messages list -a "Gmail" -m "INBOX" | jq '.[] | select(.Read==false and (.Sender | contains("boss@company.com")))'mail-app-cli search "important" | jq -r '.[] | [.Account, .Mailbox, .Subject, .Sender] | @csv'mail-app-cli search "project" | jq 'group_by(.Account) | map({account: .[0].Account, count: length})'mail-app-cli attachments list <message-id> -a "Gmail" -m "INBOX" | jq '.[].Name'mail-app-cli attachments list <message-id> -a "Gmail" -m "INBOX" | jq '.[] | select(.FileSize > 1048576)'#!/bin/bash
unread=$(mail-app-cli messages list -a "Gmail" -m "INBOX" --unread | jq 'length')
if [ $unread -gt 0 ]; then
echo "You have $unread unread messages"
fi#!/bin/bash
mail-app-cli messages list -a "Exchange" -m "Inbox" | jq -r '.[] | select(.Read==true) | .ID' \
| xargs mail-app-cli messages archive -a "Exchange" -m "Inbox"#!/bin/bash
echo "Today's Unread Email Summary"
echo "============================"
mail-app-cli mailboxes list | jq -r '.[] | select(.UnreadCount > 0) | "\(.Account)/\(.Name): \(.UnreadCount) unread"'#!/bin/bash
SENDER="colleague@company.com"
ACCOUNT="Gmail"
MAILBOX="INBOX"
# Find all messages from sender
mail-app-cli messages list -a "$ACCOUNT" -m "$MAILBOX" | jq -r ".[] | select(.Sender | contains(\"$SENDER\")) | .ID" | while read -r msg_id; do
# Get attachments for each message
mail-app-cli attachments list "$msg_id" -a "$ACCOUNT" -m "$MAILBOX" | jq -r '.[].Name' | while read -r att_name; do
echo "Saving: $att_name from message $msg_id"
mail-app-cli attachments save "$msg_id" "$att_name" -a "$ACCOUNT" -m "$MAILBOX" -o "~/Downloads/$att_name"
done
donemail-app-cli/
├── cmd/ # Cobra command definitions
│ ├── root.go
│ ├── accounts.go
│ ├── mailboxes.go
│ ├── messages.go
│ ├── send.go
│ ├── search.go
│ └── attachments.go
├── pkg/
│ └── mail/ # Mail.app AppleScript/JXA client
│ └── client.go
└── main.go
The CLI uses AppleScript and JavaScript for Automation (JXA) to interact with Mail.app. This provides:
- Native integration with Mail.app
- Access to all Mail.app features
- No external dependencies or APIs required
- Works with all mail providers configured in Mail.app
- macOS (tested on macOS 12+)
- Mail.app configured with at least one account
- Go 1.21+ (for building from source)
- Go 1.21 or higher
- macOS with Mail.app
go build -o mail-app-cli# Test account listing
./mail-app-cli accounts list
# Test mailbox listing
./mail-app-cli mailboxes list
# Test message listing
./mail-app-cli messages list -a "Your Account" -m "INBOX" --limit 5An interactive Emacs frontend for mail-app-cli is included in emacs/. It provides mailbox navigation, threaded message browsing, reading, searching, flagging, archiving, Evil mode bindings, and Emacspeak screen reader integration.
To use it in your Emacs configuration:
(add-to-list 'load-path "/path/to/mail-app-cli/emacs")
(require 'mail-app)Or with straight.el / elpaca:
(use-package mail-app
:straight (:type git :host github :repo "intelligrit/mail-app-cli" :files ("emacs/*.el")))See emacs/README.md for full configuration and keybinding documentation.
Future enhancements:
- Rules management
- Smart mailbox operations
- Signatures management
- VIP contacts
- Export/import functionality
- IMAP folder synchronization
- Draft management
Contributions are welcome! This project follows standard Go conventions.
- Fork the repository
- Create a feature branch
- Make your changes following Go best practices
- Write tests for new functionality
- Ensure all tests pass
- Commit your changes
- Push to the branch
- Open a Pull Request
mail-app-cli is developed by Intelligrit Labs, the R&D arm of Intelligrit LLC. We build tools for ourselves and release them for everyone. Intelligrit delivers AI-driven IT modernization for federal agencies.
MIT License - see LICENSE file for details
For issues, questions, or contributions, please open an issue on GitHub.
- Built with Cobra CLI framework
- Uses AppleScript and JXA for Mail.app integration
