-
Notifications
You must be signed in to change notification settings - Fork 0
Development and contributing
uv venv && uv sync --all-extras # or: pip install -e ".[dev,full]"
uv run pytest # full suite — no Mac/Mail.app required
uv run ruff check src tests~80% of this codebase (everything under read/, knowledge/, core/, storage/, server.py,
cli.py) is plain Python + SQLite and tests fully on any platform with synthetic fixtures:
-
tests/helpers.py::build_emlx_bytes()/write_message()construct real.emlxbyte streams (byte-count header, RFC822 message, XML plist trailer) on disk in a temp directory — the indexer, parser, threader, and triage heuristics all run against these for real, not mocked. -
tests/helpers.py::FakeJXAExecutoris the documented mock boundary for the write layer: programmed per JXA function name with a Python callable, records every call made. Used bytests/test_resolver.pyandtests/test_write_layer.pyto verify the resolution algorithm,guard()'s every safety check, andundo_last()— without touching Mail.app at all. -
tests/test_jxa_executor.pyis the one place that runs realosascriptsubprocesses — deliberately scoped to scripts that never callApplication("Mail"), so no Automation permission is needed, while still genuinely exercising the timeout/process-group-kill mechanics (not mocked). -
tests/test_vector_search.pyuses a deterministicFakeEmbeddingBackend(keyword-presence vectors, no ML) against a realsqlite-vecextension (pytest.importorskip("sqlite_vec")— skips gracefully if not installed; the[dev]extra includes it specifically so CI exercises this path rather than always skipping). -
tests/test_account_names.pybuilds a synthetic, real-schema-shapedAccounts4.sqlitefixture (same pattern as the.emlxfixtures — construct the real on-disk format, don't mock the lookup) to testread/account_names.py'sZPARENTACCOUNT-chain walk and cycle guard without depending on whatever's actually configured on the machine running the suite;read/indexer.py::build_index()takes anaccounts_db_pathoverride for the same reason. -
tests/test_attachment_extract.pyusestests/helpers.py::make_test_pdf()/make_test_docx()(real minimal PDF/DOCX bytes pypdf and the stdlib parser actually read) pluswrite_message_with_attachment()(a real multipart.emlx), so the extract → FTS →scope=attachmentspath is exercised end-to-end against genuine file formats, and a corrupt fixture proves it degrades to "skipped" rather than crashing the build.pytest.importorskipskips the module cleanly when the[attachments]extra isn't installed; the[dev]extra includespypdfso CI runs it for real.
The only things this suite cannot verify without a real, fully-configured Mac: the actual
write/scripts/mail_core.js JXA against a live Mail.app (compose/reply/forward/move/trash/
drafts), and the Apple NaturalLanguage/Foundation Models integrations (PyObjC and Swift-helper
runtime behavior — see Search and the
swift/foundation-models-summarizer/README.md
for what was verified by compiling vs. what needs manual verification).
-
apple-mail-mcp index build --fullthenindex status. -
apple-mail-mcp search "<term>" --highlight,get_email_thread,overview,needs-response. -
Non-destructive first:
apple-mail-mcp move <id> --to-mailbox Archive --dry-run,apple-mail-mcp trash --action delete_permanent ...(defaults todry_run=true). -
apple-mail-mcp compose --account ... --to <yourself> --subject test --body test --attachment <file> --mode send, then confirm it arrives from the account you named (not the default one) with the attachment. This exact step is what surfaced four real JXA bugs the mocked unit tests couldn't —.make()on recipients/attachments failing (-10024),make(withProperties)silently dropping the subject (→ Mail's "no subject" dialog blocking the send), the account argument being ignored, and the sentOutgoingMessagelingering inoutgoingMessages(). Do this on real Mail before trusting compose/reply/forward. - A single real
move, thenapple-mail-mcp undo-lastto confirm the round-trip. Alsoset_flag_color+undo-last(the resolver must find the message by its bracket-stripped Message-ID — a bracketed query silently times out on a large mailbox). -
update_email_statusflag/unflag. - Confirm
--read-onlyblocks every write tool (should fail in milliseconds, never launching Mail.app — see Performance & benchmarks for why this is specifically checked). - Register with an actual MCP client (see Install per client) and run
recipe run daily-triage.
Any change to a subsystem updates its mapped Wiki page in the same commit — see the
knowledge map in
CLAUDE.md. Each Wiki
page's front-matter (covers: source globs, last_verified: date) is checked by
scripts/check_docs_sync.py, wired into pre-commit and CI — it warns (not a hard failure, to
avoid false positives from unrelated changes) when a covered source file's mtime is newer than
the page's recorded last_verified date.
GitHub Actions (.github/workflows/ci.yml) runs on macOS runners (this project's core logic and
the documented test boundary both require macOS for the real-osascript and real-sqlite-vec
tests to run, not just skip): ruff check, pytest, and scripts/check_docs_sync.py.
.github/workflows/publish.yml publishes to PyPI on every GitHub Release using PyPI Trusted
Publishing (OIDC): PyPI verifies the GitHub Actions identity directly, so there is no API token
stored in the repo or anywhere else — nothing to leak or rotate. The canonical MCP install
(uvx cobos-apple-mail-mcp serve, pipx install cobos-apple-mail-mcp) pulls from here.
One-time setup (done once by the project owner, all on the web, no token generated):
- On PyPI → Your projects → (or Publishing for a not-yet-existing project) → Add a pending
publisher with: PyPI project name
cobos-apple-mail-mcp, ownerErnestoCobos, repositorycobos-apple-mail-mcp, workflow filenamepublish.yml. - Cut a GitHub Release (or run the Publish to PyPI workflow via Actions → Run workflow). The workflow builds the sdist+wheel and uploads them; PyPI accepts them because the OIDC identity matches the pending publisher.
After the first successful publish the pending publisher becomes a normal trusted publisher and
every future release publishes automatically. Bump version in pyproject.toml (and
__init__.py) before tagging.
make pyz # builds dist/apple-mail-mcp{,-full}.pyz
shasum -a 256 dist/*.pyz > dist/SHA256SUMS.txt
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z
gh release create vX.Y.Z dist/apple-mail-mcp.pyz dist/apple-mail-mcp-full.pyz dist/SHA256SUMS.txt \
--title "vX.Y.Z" --notes-file <path> # this triggers the PyPI publish workflow
scripts/publish_wiki.sh # syncs docs/wiki/ -> the GitHub wiki repoThe GitHub wiki repo (*.wiki.git) doesn't exist until its first page is created once through
the web UI ("Create the first page") — there's no API/git way to bootstrap it (verified: both a
fresh clone and a direct push to an unwritten wiki repo fail with "Repository not found"). Do
this once per repo before the first scripts/publish_wiki.sh run.
scripts/build_pyz.sh prefers this checkout's own .venv (see
Single-file packaging) — run make pyz from a checkout with
uv sync --all-extras already run, not from an ad-hoc shell with a different python3 on $PATH.
Python 3.10+, type hints everywhere, pydantic models for all tool I/O. No comments explaining what code does — only the non-obvious why (a constraint, a workaround, an invariant). See CLAUDE.md for the full conventions list and the hard invariants every change must preserve.