Skip to content

Commit 136e5af

Browse files
MultiMailH179922
authored andcommitted
feat(client): trust-ladder + spam-screening methods, bump 0.2.0 (GHST-862)
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).
1 parent 74f22ca commit 136e5af

3 files changed

Lines changed: 165 additions & 1 deletion

File tree

multimail/client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ def download_attachment(self, mailbox_id: str, email_id: str, filename: str) ->
135135
_raise_for_status(resp)
136136
return resp.content
137137

138+
def report_spam(self, email_id: str) -> dict:
139+
"""Quarantine an email as spam. Returns {id, status: 'spam_quarantined', user_label: 'spam'}."""
140+
return self._request("POST", f"/v1/emails/{email_id}/report-spam")
141+
142+
def not_spam(self, email_id: str) -> dict:
143+
"""Clear a spam label, restoring the email to the inbox. Returns {id, status: 'unread', user_label: 'not_spam'}."""
144+
return self._request("POST", f"/v1/emails/{email_id}/not-spam")
145+
138146
# ── Tags ─────────────────────────────────────────────────
139147

140148
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
173181
body["reason"] = reason
174182
return self._request("POST", "/v1/oversight/decide", json=body)
175183

184+
def request_upgrade(self, mailbox_id: str, target_mode: str) -> dict:
185+
"""Request an oversight-mode change (the trust ladder). Sends an approval code to the
186+
configured oversight email; the human shares it back to apply_upgrade(). target_mode is one
187+
of: read_only, gated_all, gated_send, monitored, autonomous. Returns {status: 'upgrade_requested', ...}."""
188+
return self._request("POST", f"/v1/mailboxes/{mailbox_id}/request-upgrade", json={"target_mode": target_mode})
189+
190+
def apply_upgrade(self, mailbox_id: str, code: str) -> dict:
191+
"""Apply a previously-requested oversight upgrade using the code the human shared back.
192+
Returns {status: 'upgraded', ...}. There is no automatic grant — the code is required."""
193+
return self._request("POST", f"/v1/mailboxes/{mailbox_id}/upgrade", json={"code": code})
194+
176195
# ── API Keys ─────────────────────────────────────────────
177196

178197
def list_api_keys(self) -> list[dict]:
@@ -335,6 +354,14 @@ async def download_attachment(self, mailbox_id: str, email_id: str, filename: st
335354
_raise_for_status(resp)
336355
return resp.content
337356

357+
async def report_spam(self, email_id: str) -> dict:
358+
"""Quarantine an email as spam. Returns {id, status: 'spam_quarantined', user_label: 'spam'}."""
359+
return await self._request("POST", f"/v1/emails/{email_id}/report-spam")
360+
361+
async def not_spam(self, email_id: str) -> dict:
362+
"""Clear a spam label, restoring the email to the inbox. Returns {id, status: 'unread', user_label: 'not_spam'}."""
363+
return await self._request("POST", f"/v1/emails/{email_id}/not-spam")
364+
338365
# ── Tags ─────────────────────────────────────────────────
339366

340367
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)
373400
body["reason"] = reason
374401
return await self._request("POST", "/v1/oversight/decide", json=body)
375402

403+
async def request_upgrade(self, mailbox_id: str, target_mode: str) -> dict:
404+
"""Request an oversight-mode change (the trust ladder). Sends an approval code to the
405+
configured oversight email; the human shares it back to apply_upgrade(). target_mode is one
406+
of: read_only, gated_all, gated_send, monitored, autonomous. Returns {status: 'upgrade_requested', ...}."""
407+
return await self._request("POST", f"/v1/mailboxes/{mailbox_id}/request-upgrade", json={"target_mode": target_mode})
408+
409+
async def apply_upgrade(self, mailbox_id: str, code: str) -> dict:
410+
"""Apply a previously-requested oversight upgrade using the code the human shared back.
411+
Returns {status: 'upgraded', ...}. There is no automatic grant — the code is required."""
412+
return await self._request("POST", f"/v1/mailboxes/{mailbox_id}/upgrade", json={"code": code})
413+
376414
# ── API Keys ─────────────────────────────────────────────
377415

378416
async def list_api_keys(self) -> list[dict]:

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "multimail"
7-
version = "0.1.1"
7+
version = "0.2.0"
88
description = "Python SDK for the MultiMail API — email infrastructure for AI agents"
99
readme = "README.md"
1010
license = "MIT"
@@ -34,3 +34,6 @@ dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.20"]
3434
Homepage = "https://multimail.dev"
3535
Documentation = "https://multimail.dev/docs"
3636
Repository = "https://github.com/multimail-dev/multimail-python"
37+
38+
[tool.pytest.ini_options]
39+
asyncio_mode = "auto"

tests/test_trust_ladder_spam.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Tests for the trust-ladder (oversight upgrade) and spam-screening client methods.
2+
3+
These exercise the four methods added for API parity (GHST-862): report_spam, not_spam,
4+
request_upgrade, apply_upgrade — on both the sync and async clients. Endpoints and request
5+
shapes are pinned to the real worker handlers in src/workers/api.ts:
6+
POST /v1/emails/{id}/report-spam -> {id, status: 'spam_quarantined', user_label: 'spam'}
7+
POST /v1/emails/{id}/not-spam -> {id, status: 'unread', user_label: 'not_spam'}
8+
POST /v1/mailboxes/{id}/request-upgrade body {target_mode} -> {status: 'upgrade_requested'}
9+
POST /v1/mailboxes/{id}/upgrade body {code} -> {status: 'upgraded'}
10+
"""
11+
import json
12+
13+
import httpx
14+
import pytest
15+
import respx
16+
17+
from multimail import AsyncMultiMail, MultiMail, MultiMailError, NotFoundError
18+
19+
BASE = "https://api.multimail.dev"
20+
KEY = "mm_live_test_key"
21+
22+
23+
# ── Spam screening ────────────────────────────────────────────
24+
25+
@respx.mock
26+
def test_report_spam_posts_no_body_and_parses_result():
27+
route = respx.post(f"{BASE}/v1/emails/em_1/report-spam").mock(
28+
return_value=httpx.Response(
29+
200, json={"id": "em_1", "status": "spam_quarantined", "user_label": "spam"}
30+
)
31+
)
32+
with MultiMail(KEY, base_url=BASE) as c:
33+
out = c.report_spam("em_1")
34+
assert route.called
35+
assert out == {"id": "em_1", "status": "spam_quarantined", "user_label": "spam"}
36+
# report-spam takes no request body
37+
assert route.calls.last.request.content in (b"", b"null")
38+
39+
40+
@respx.mock
41+
def test_not_spam_restores_to_inbox():
42+
route = respx.post(f"{BASE}/v1/emails/em_2/not-spam").mock(
43+
return_value=httpx.Response(
44+
200, json={"id": "em_2", "status": "unread", "user_label": "not_spam"}
45+
)
46+
)
47+
with MultiMail(KEY, base_url=BASE) as c:
48+
out = c.not_spam("em_2")
49+
assert route.called
50+
assert out["status"] == "unread"
51+
assert out["user_label"] == "not_spam"
52+
53+
54+
# ── Trust ladder (oversight upgrade) ──────────────────────────
55+
56+
@respx.mock
57+
def test_request_upgrade_sends_target_mode():
58+
route = respx.post(f"{BASE}/v1/mailboxes/mb_1/request-upgrade").mock(
59+
return_value=httpx.Response(200, json={"status": "upgrade_requested"})
60+
)
61+
with MultiMail(KEY, base_url=BASE) as c:
62+
out = c.request_upgrade("mb_1", "monitored")
63+
assert out["status"] == "upgrade_requested"
64+
assert json.loads(route.calls.last.request.content) == {"target_mode": "monitored"}
65+
66+
67+
@respx.mock
68+
def test_apply_upgrade_sends_code():
69+
route = respx.post(f"{BASE}/v1/mailboxes/mb_1/upgrade").mock(
70+
return_value=httpx.Response(200, json={"status": "upgraded"})
71+
)
72+
with MultiMail(KEY, base_url=BASE) as c:
73+
out = c.apply_upgrade("mb_1", "ABC123")
74+
assert out["status"] == "upgraded"
75+
assert json.loads(route.calls.last.request.content) == {"code": "ABC123"}
76+
77+
78+
# ── Error mapping ─────────────────────────────────────────────
79+
80+
@respx.mock
81+
def test_report_spam_404_maps_to_notfound():
82+
respx.post(f"{BASE}/v1/emails/missing/report-spam").mock(
83+
return_value=httpx.Response(404, json={"error": "Email not found"})
84+
)
85+
with MultiMail(KEY, base_url=BASE) as c:
86+
with pytest.raises(NotFoundError):
87+
c.report_spam("missing")
88+
89+
90+
@respx.mock
91+
def test_request_upgrade_403_maps_to_error():
92+
respx.post(f"{BASE}/v1/mailboxes/mb_1/request-upgrade").mock(
93+
return_value=httpx.Response(403, json={"error": "Requires send scope"})
94+
)
95+
with MultiMail(KEY, base_url=BASE) as c:
96+
with pytest.raises(MultiMailError):
97+
c.request_upgrade("mb_1", "monitored")
98+
99+
100+
# ── Async parity ──────────────────────────────────────────────
101+
102+
@respx.mock
103+
async def test_async_spam_and_trust_ladder():
104+
respx.post(f"{BASE}/v1/emails/em_1/report-spam").mock(
105+
return_value=httpx.Response(
106+
200, json={"id": "em_1", "status": "spam_quarantined", "user_label": "spam"}
107+
)
108+
)
109+
req = respx.post(f"{BASE}/v1/mailboxes/mb_1/request-upgrade").mock(
110+
return_value=httpx.Response(200, json={"status": "upgrade_requested"})
111+
)
112+
appl = respx.post(f"{BASE}/v1/mailboxes/mb_1/upgrade").mock(
113+
return_value=httpx.Response(200, json={"status": "upgraded"})
114+
)
115+
async with AsyncMultiMail(KEY, base_url=BASE) as c:
116+
spam = await c.report_spam("em_1")
117+
r1 = await c.request_upgrade("mb_1", "gated_send")
118+
r2 = await c.apply_upgrade("mb_1", "CODE9")
119+
assert spam["status"] == "spam_quarantined"
120+
assert r1["status"] == "upgrade_requested"
121+
assert r2["status"] == "upgraded"
122+
assert json.loads(req.calls.last.request.content) == {"target_mode": "gated_send"}
123+
assert json.loads(appl.calls.last.request.content) == {"code": "CODE9"}

0 commit comments

Comments
 (0)