Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions multimail/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
123 changes: 123 additions & 0 deletions tests/test_trust_ladder_spam.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading