From 5d3143dd2d690bd927d363ed28fb6315a989b9a9 Mon Sep 17 00:00:00 2001 From: MultiMail Date: Sat, 27 Jun 2026 21:07:51 -0400 Subject: [PATCH] feat(client): trust-ladder + spam-screening methods, bump 0.2.0 (GHST-862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 agent-facing methods to both the sync and async clients, closing the genuine API-parity gap (the rest of the 37-method surface already matched): - request_upgrade(mailbox_id, target_mode) -> POST /v1/mailboxes/{id}/request-upgrade - apply_upgrade(mailbox_id, code) -> POST /v1/mailboxes/{id}/upgrade - report_spam(email_id) -> POST /v1/emails/{id}/report-spam - not_spam(email_id) -> POST /v1/emails/{id}/not-spam The upgrade pair is the trust ladder — an agent can now climb oversight modes (request sends a code to the human's oversight email; apply consumes the code the human shares back; no automatic grant). Spam pair is inbox screening. Every endpoint/body/response is pinned to the real worker handlers in src/workers/api.ts (no fabricated routes). Adds tests/ (respx-mocked: happy paths, request-body shapes, 404/403 error mapping, async parity) — 7 tests, all green — and pytest asyncio config. Minor bump 0.1.1 -> 0.2.0 (feature add). --- multimail/client.py | 38 ++++++++++ pyproject.toml | 5 +- tests/test_trust_ladder_spam.py | 123 ++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/test_trust_ladder_spam.py diff --git a/multimail/client.py b/multimail/client.py index e8e0a37..70126ca 100644 --- a/multimail/client.py +++ b/multimail/client.py @@ -135,6 +135,14 @@ def download_attachment(self, mailbox_id: str, email_id: str, filename: str) -> _raise_for_status(resp) return resp.content + def report_spam(self, email_id: str) -> dict: + """Quarantine an email as spam. Returns {id, status: 'spam_quarantined', user_label: 'spam'}.""" + return self._request("POST", f"/v1/emails/{email_id}/report-spam") + + def not_spam(self, email_id: str) -> dict: + """Clear a spam label, restoring the email to the inbox. Returns {id, status: 'unread', user_label: 'not_spam'}.""" + return self._request("POST", f"/v1/emails/{email_id}/not-spam") + # ── Tags ───────────────────────────────────────────────── def get_tags(self, mailbox_id: str, email_id: str) -> dict: @@ -173,6 +181,17 @@ def decide(self, email_id: str, action: str, *, reason: str | None = None) -> di body["reason"] = reason return self._request("POST", "/v1/oversight/decide", json=body) + def request_upgrade(self, mailbox_id: str, target_mode: str) -> dict: + """Request an oversight-mode change (the trust ladder). Sends an approval code to the + configured oversight email; the human shares it back to apply_upgrade(). target_mode is one + of: read_only, gated_all, gated_send, monitored, autonomous. Returns {status: 'upgrade_requested', ...}.""" + return self._request("POST", f"/v1/mailboxes/{mailbox_id}/request-upgrade", json={"target_mode": target_mode}) + + def apply_upgrade(self, mailbox_id: str, code: str) -> dict: + """Apply a previously-requested oversight upgrade using the code the human shared back. + Returns {status: 'upgraded', ...}. There is no automatic grant — the code is required.""" + return self._request("POST", f"/v1/mailboxes/{mailbox_id}/upgrade", json={"code": code}) + # ── API Keys ───────────────────────────────────────────── def list_api_keys(self) -> list[dict]: @@ -335,6 +354,14 @@ async def download_attachment(self, mailbox_id: str, email_id: str, filename: st _raise_for_status(resp) return resp.content + async def report_spam(self, email_id: str) -> dict: + """Quarantine an email as spam. Returns {id, status: 'spam_quarantined', user_label: 'spam'}.""" + return await self._request("POST", f"/v1/emails/{email_id}/report-spam") + + async def not_spam(self, email_id: str) -> dict: + """Clear a spam label, restoring the email to the inbox. Returns {id, status: 'unread', user_label: 'not_spam'}.""" + return await self._request("POST", f"/v1/emails/{email_id}/not-spam") + # ── Tags ───────────────────────────────────────────────── async def get_tags(self, mailbox_id: str, email_id: str) -> dict: @@ -373,6 +400,17 @@ async def decide(self, email_id: str, action: str, *, reason: str | None = None) body["reason"] = reason return await self._request("POST", "/v1/oversight/decide", json=body) + async def request_upgrade(self, mailbox_id: str, target_mode: str) -> dict: + """Request an oversight-mode change (the trust ladder). Sends an approval code to the + configured oversight email; the human shares it back to apply_upgrade(). target_mode is one + of: read_only, gated_all, gated_send, monitored, autonomous. Returns {status: 'upgrade_requested', ...}.""" + return await self._request("POST", f"/v1/mailboxes/{mailbox_id}/request-upgrade", json={"target_mode": target_mode}) + + async def apply_upgrade(self, mailbox_id: str, code: str) -> dict: + """Apply a previously-requested oversight upgrade using the code the human shared back. + Returns {status: 'upgraded', ...}. There is no automatic grant — the code is required.""" + return await self._request("POST", f"/v1/mailboxes/{mailbox_id}/upgrade", json={"code": code}) + # ── API Keys ───────────────────────────────────────────── async def list_api_keys(self) -> list[dict]: diff --git a/pyproject.toml b/pyproject.toml index 574e14f..d7e0ea8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "multimail" -version = "0.1.1" +version = "0.2.0" description = "Python SDK for the MultiMail API — email infrastructure for AI agents" readme = "README.md" license = "MIT" @@ -34,3 +34,6 @@ dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.20"] Homepage = "https://multimail.dev" Documentation = "https://multimail.dev/docs" Repository = "https://github.com/multimail-dev/multimail-python" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/tests/test_trust_ladder_spam.py b/tests/test_trust_ladder_spam.py new file mode 100644 index 0000000..df74dc3 --- /dev/null +++ b/tests/test_trust_ladder_spam.py @@ -0,0 +1,123 @@ +"""Tests for the trust-ladder (oversight upgrade) and spam-screening client methods. + +These exercise the four methods added for API parity (GHST-862): report_spam, not_spam, +request_upgrade, apply_upgrade — on both the sync and async clients. Endpoints and request +shapes are pinned to the real worker handlers in src/workers/api.ts: + POST /v1/emails/{id}/report-spam -> {id, status: 'spam_quarantined', user_label: 'spam'} + POST /v1/emails/{id}/not-spam -> {id, status: 'unread', user_label: 'not_spam'} + POST /v1/mailboxes/{id}/request-upgrade body {target_mode} -> {status: 'upgrade_requested'} + POST /v1/mailboxes/{id}/upgrade body {code} -> {status: 'upgraded'} +""" +import json + +import httpx +import pytest +import respx + +from multimail import AsyncMultiMail, MultiMail, MultiMailError, NotFoundError + +BASE = "https://api.multimail.dev" +KEY = "mm_live_test_key" + + +# ── Spam screening ──────────────────────────────────────────── + +@respx.mock +def test_report_spam_posts_no_body_and_parses_result(): + route = respx.post(f"{BASE}/v1/emails/em_1/report-spam").mock( + return_value=httpx.Response( + 200, json={"id": "em_1", "status": "spam_quarantined", "user_label": "spam"} + ) + ) + with MultiMail(KEY, base_url=BASE) as c: + out = c.report_spam("em_1") + assert route.called + assert out == {"id": "em_1", "status": "spam_quarantined", "user_label": "spam"} + # report-spam takes no request body + assert route.calls.last.request.content in (b"", b"null") + + +@respx.mock +def test_not_spam_restores_to_inbox(): + route = respx.post(f"{BASE}/v1/emails/em_2/not-spam").mock( + return_value=httpx.Response( + 200, json={"id": "em_2", "status": "unread", "user_label": "not_spam"} + ) + ) + with MultiMail(KEY, base_url=BASE) as c: + out = c.not_spam("em_2") + assert route.called + assert out["status"] == "unread" + assert out["user_label"] == "not_spam" + + +# ── Trust ladder (oversight upgrade) ────────────────────────── + +@respx.mock +def test_request_upgrade_sends_target_mode(): + route = respx.post(f"{BASE}/v1/mailboxes/mb_1/request-upgrade").mock( + return_value=httpx.Response(200, json={"status": "upgrade_requested"}) + ) + with MultiMail(KEY, base_url=BASE) as c: + out = c.request_upgrade("mb_1", "monitored") + assert out["status"] == "upgrade_requested" + assert json.loads(route.calls.last.request.content) == {"target_mode": "monitored"} + + +@respx.mock +def test_apply_upgrade_sends_code(): + route = respx.post(f"{BASE}/v1/mailboxes/mb_1/upgrade").mock( + return_value=httpx.Response(200, json={"status": "upgraded"}) + ) + with MultiMail(KEY, base_url=BASE) as c: + out = c.apply_upgrade("mb_1", "ABC123") + assert out["status"] == "upgraded" + assert json.loads(route.calls.last.request.content) == {"code": "ABC123"} + + +# ── Error mapping ───────────────────────────────────────────── + +@respx.mock +def test_report_spam_404_maps_to_notfound(): + respx.post(f"{BASE}/v1/emails/missing/report-spam").mock( + return_value=httpx.Response(404, json={"error": "Email not found"}) + ) + with MultiMail(KEY, base_url=BASE) as c: + with pytest.raises(NotFoundError): + c.report_spam("missing") + + +@respx.mock +def test_request_upgrade_403_maps_to_error(): + respx.post(f"{BASE}/v1/mailboxes/mb_1/request-upgrade").mock( + return_value=httpx.Response(403, json={"error": "Requires send scope"}) + ) + with MultiMail(KEY, base_url=BASE) as c: + with pytest.raises(MultiMailError): + c.request_upgrade("mb_1", "monitored") + + +# ── Async parity ────────────────────────────────────────────── + +@respx.mock +async def test_async_spam_and_trust_ladder(): + respx.post(f"{BASE}/v1/emails/em_1/report-spam").mock( + return_value=httpx.Response( + 200, json={"id": "em_1", "status": "spam_quarantined", "user_label": "spam"} + ) + ) + req = respx.post(f"{BASE}/v1/mailboxes/mb_1/request-upgrade").mock( + return_value=httpx.Response(200, json={"status": "upgrade_requested"}) + ) + appl = respx.post(f"{BASE}/v1/mailboxes/mb_1/upgrade").mock( + return_value=httpx.Response(200, json={"status": "upgraded"}) + ) + async with AsyncMultiMail(KEY, base_url=BASE) as c: + spam = await c.report_spam("em_1") + r1 = await c.request_upgrade("mb_1", "gated_send") + r2 = await c.apply_upgrade("mb_1", "CODE9") + assert spam["status"] == "spam_quarantined" + assert r1["status"] == "upgrade_requested" + assert r2["status"] == "upgraded" + assert json.loads(req.calls.last.request.content) == {"target_mode": "gated_send"} + assert json.loads(appl.calls.last.request.content) == {"code": "CODE9"}