primitif

Primitif Python SDK — Mail and Approval APIs for AI agents.

 1"""Primitif Python SDK — Mail and Approval APIs for AI agents."""
 2
 3from primitif._models import PaginatedList
 4from primitif._version import __version__
 5from primitif._exceptions import (
 6    PrimitifError,
 7    AuthError,
 8    NotFoundError,
 9    ConflictError,
10    ValidationError,
11    RateLimitError,
12    ServerError,
13    NetworkError,
14    InvalidSignature,
15)
16from primitif._webhook import verify_webhook
17from primitif.client import Primitif
18from primitif.mail.client import Mailbox
19from primitif.approval import require_approval
20
21__all__ = [
22    "__version__",
23    "Primitif",
24    "Mailbox",
25    "PaginatedList",
26    "verify_webhook",
27    "PrimitifError",
28    "AuthError",
29    "NotFoundError",
30    "ConflictError",
31    "ValidationError",
32    "RateLimitError",
33    "ServerError",
34    "NetworkError",
35    "InvalidSignature",
36    "require_approval",
37]
__version__ = '0.0.9'
class Primitif:
 23class Primitif:
 24    """Primitif SDK client — one API key, access to all products.
 25
 26    Usage::
 27
 28        with Primitif() as p:  # reads PRIMITIF_API_KEY from env
 29            mb = p.mail.create_mailbox(name="agent", ttl=3600)
 30            mb.send(to="user@example.com", subject="Hi", body_text="Hello")
 31            mb.delete()
 32
 33            req = p.approval.create_request(action="Send contract")
 34
 35            ns = p.namespaces.create("staging")
 36    """
 37
 38    def __init__(
 39        self,
 40        api_key: str | None = None,
 41        *,
 42        mail_url: str | None = None,
 43        approval_url: str | None = None,
 44        audit_url: str | None = None,
 45        platform_url: str | None = None,
 46        max_retries: int = DEFAULT_MAX_RETRIES,
 47        timeout: float = 30.0,
 48    ) -> None:
 49        """Create a Primitif client with access to all products.
 50
 51        Args:
 52            api_key: Developer API key (``pk_live_...``). Falls back to
 53                the ``PRIMITIF_API_KEY`` environment variable if omitted.
 54            mail_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL``
 55                env var or ``https://api.mail.primitif.ai``.
 56            approval_url: Approval API base URL. Defaults to
 57                ``PRIMITIF_APPROVAL_URL`` env var or
 58                ``https://api.approval.primitif.ai``.
 59            audit_url: Audit API base URL. Defaults to
 60                ``PRIMITIF_AUDIT_URL`` env var or
 61                ``https://api.audit.primitif.ai``.
 62            platform_url: Platform API base URL. Defaults to
 63                ``PRIMITIF_PLATFORM_URL`` env var or
 64                ``https://api.primitif.ai``.
 65            max_retries: Number of automatic retries on transient errors.
 66                Defaults to 2.
 67            timeout: HTTP request timeout in seconds. Defaults to 30.0.
 68
 69        Raises:
 70            PrimitifError: If no API key is provided and ``PRIMITIF_API_KEY``
 71                is not set.
 72
 73        Example:
 74            >>> with Primitif() as p:
 75            ...     mb = p.mail.create_mailbox(name="agent", ttl=3600)
 76            ...     mb.send(to="user@example.com", subject="Hi", body_text="Hello")
 77            ...     mb.delete()
 78        """
 79        from primitif._version import __version__
 80
 81        if api_key is None:
 82            api_key = os.environ.get("PRIMITIF_API_KEY")
 83        if api_key is not None:
 84            api_key = api_key.strip()
 85        if not api_key:
 86            raise PrimitifError(
 87                message="No API key provided. Pass api_key= or set PRIMITIF_API_KEY env var."
 88            )
 89
 90        if mail_url is None:
 91            mail_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_MAIL_URL)
 92        if approval_url is None:
 93            approval_url = os.environ.get("PRIMITIF_APPROVAL_URL", DEFAULT_APPROVAL_URL)
 94        if audit_url is None:
 95            audit_url = os.environ.get("PRIMITIF_AUDIT_URL", DEFAULT_AUDIT_URL)
 96        if platform_url is None:
 97            platform_url = os.environ.get("PRIMITIF_PLATFORM_URL", DEFAULT_PLATFORM_URL)
 98
 99        headers = {
100            "Authorization": f"Bearer {api_key}",
101            "User-Agent": f"primitif/{__version__}",
102        }
103
104        self._mail_client = httpx.Client(
105            base_url=mail_url.rstrip("/"),
106            headers=headers,
107            timeout=timeout,
108        )
109        try:
110            self._approval_client = httpx.Client(
111                base_url=approval_url.rstrip("/"),
112                headers=headers,
113                timeout=timeout,
114            )
115        except BaseException:
116            self._mail_client.close()
117            raise
118        try:
119            self._audit_client = httpx.Client(
120                base_url=audit_url.rstrip("/"),
121                headers=headers,
122                timeout=timeout,
123            )
124        except BaseException:
125            self._mail_client.close()
126            self._approval_client.close()
127            raise
128        try:
129            self._platform_client = httpx.Client(
130                base_url=platform_url.rstrip("/"),
131                headers=headers,
132                timeout=timeout,
133            )
134        except BaseException:
135            self._mail_client.close()
136            self._approval_client.close()
137            self._audit_client.close()
138            raise
139        self._max_retries = max_retries
140
141        self.mail = MailAdmin(self._mail_client, max_retries, mail_url.rstrip("/"))
142        self.approval = ApprovalAdmin(self._approval_client, max_retries)
143        self.audit = AuditAdmin(self._audit_client, max_retries)
144        self.namespaces = NamespaceAdmin(self._platform_client, max_retries)
145
146    def close(self) -> None:
147        """Close all HTTP clients.
148
149        Releases the underlying connection pools for Mail, Approval, Audit,
150        and Platform APIs. Called automatically when used as a context manager.
151
152        Example:
153            >>> p = Primitif()
154            >>> # ... use p ...
155            >>> p.close()
156        """
157        self._mail_client.close()
158        self._approval_client.close()
159        self._audit_client.close()
160        self._platform_client.close()
161
162    def __enter__(self) -> Primitif:
163        return self
164
165    def __exit__(self, *args) -> None:
166        self.close()
167
168    def __repr__(self) -> str:
169        return "Primitif()"

Primitif SDK client — one API key, access to all products.

Usage::

with Primitif() as p:  # reads PRIMITIF_API_KEY from env
    mb = p.mail.create_mailbox(name="agent", ttl=3600)
    mb.send(to="user@example.com", subject="Hi", body_text="Hello")
    mb.delete()

    req = p.approval.create_request(action="Send contract")

    ns = p.namespaces.create("staging")
Primitif( api_key: str | None = None, *, mail_url: str | None = None, approval_url: str | None = None, audit_url: str | None = None, platform_url: str | None = None, max_retries: int = 2, timeout: float = 30.0)
 38    def __init__(
 39        self,
 40        api_key: str | None = None,
 41        *,
 42        mail_url: str | None = None,
 43        approval_url: str | None = None,
 44        audit_url: str | None = None,
 45        platform_url: str | None = None,
 46        max_retries: int = DEFAULT_MAX_RETRIES,
 47        timeout: float = 30.0,
 48    ) -> None:
 49        """Create a Primitif client with access to all products.
 50
 51        Args:
 52            api_key: Developer API key (``pk_live_...``). Falls back to
 53                the ``PRIMITIF_API_KEY`` environment variable if omitted.
 54            mail_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL``
 55                env var or ``https://api.mail.primitif.ai``.
 56            approval_url: Approval API base URL. Defaults to
 57                ``PRIMITIF_APPROVAL_URL`` env var or
 58                ``https://api.approval.primitif.ai``.
 59            audit_url: Audit API base URL. Defaults to
 60                ``PRIMITIF_AUDIT_URL`` env var or
 61                ``https://api.audit.primitif.ai``.
 62            platform_url: Platform API base URL. Defaults to
 63                ``PRIMITIF_PLATFORM_URL`` env var or
 64                ``https://api.primitif.ai``.
 65            max_retries: Number of automatic retries on transient errors.
 66                Defaults to 2.
 67            timeout: HTTP request timeout in seconds. Defaults to 30.0.
 68
 69        Raises:
 70            PrimitifError: If no API key is provided and ``PRIMITIF_API_KEY``
 71                is not set.
 72
 73        Example:
 74            >>> with Primitif() as p:
 75            ...     mb = p.mail.create_mailbox(name="agent", ttl=3600)
 76            ...     mb.send(to="user@example.com", subject="Hi", body_text="Hello")
 77            ...     mb.delete()
 78        """
 79        from primitif._version import __version__
 80
 81        if api_key is None:
 82            api_key = os.environ.get("PRIMITIF_API_KEY")
 83        if api_key is not None:
 84            api_key = api_key.strip()
 85        if not api_key:
 86            raise PrimitifError(
 87                message="No API key provided. Pass api_key= or set PRIMITIF_API_KEY env var."
 88            )
 89
 90        if mail_url is None:
 91            mail_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_MAIL_URL)
 92        if approval_url is None:
 93            approval_url = os.environ.get("PRIMITIF_APPROVAL_URL", DEFAULT_APPROVAL_URL)
 94        if audit_url is None:
 95            audit_url = os.environ.get("PRIMITIF_AUDIT_URL", DEFAULT_AUDIT_URL)
 96        if platform_url is None:
 97            platform_url = os.environ.get("PRIMITIF_PLATFORM_URL", DEFAULT_PLATFORM_URL)
 98
 99        headers = {
100            "Authorization": f"Bearer {api_key}",
101            "User-Agent": f"primitif/{__version__}",
102        }
103
104        self._mail_client = httpx.Client(
105            base_url=mail_url.rstrip("/"),
106            headers=headers,
107            timeout=timeout,
108        )
109        try:
110            self._approval_client = httpx.Client(
111                base_url=approval_url.rstrip("/"),
112                headers=headers,
113                timeout=timeout,
114            )
115        except BaseException:
116            self._mail_client.close()
117            raise
118        try:
119            self._audit_client = httpx.Client(
120                base_url=audit_url.rstrip("/"),
121                headers=headers,
122                timeout=timeout,
123            )
124        except BaseException:
125            self._mail_client.close()
126            self._approval_client.close()
127            raise
128        try:
129            self._platform_client = httpx.Client(
130                base_url=platform_url.rstrip("/"),
131                headers=headers,
132                timeout=timeout,
133            )
134        except BaseException:
135            self._mail_client.close()
136            self._approval_client.close()
137            self._audit_client.close()
138            raise
139        self._max_retries = max_retries
140
141        self.mail = MailAdmin(self._mail_client, max_retries, mail_url.rstrip("/"))
142        self.approval = ApprovalAdmin(self._approval_client, max_retries)
143        self.audit = AuditAdmin(self._audit_client, max_retries)
144        self.namespaces = NamespaceAdmin(self._platform_client, max_retries)

Create a Primitif client with access to all products.

Args: api_key: Developer API key (pk_live_...). Falls back to the PRIMITIF_API_KEY environment variable if omitted. mail_url: Mail API base URL. Defaults to PRIMITIF_MAIL_URL env var or https://api.mail.primitif.ai. approval_url: Approval API base URL. Defaults to PRIMITIF_APPROVAL_URL env var or https://api.approval.primitif.ai. audit_url: Audit API base URL. Defaults to PRIMITIF_AUDIT_URL env var or https://api.audit.primitif.ai. platform_url: Platform API base URL. Defaults to PRIMITIF_PLATFORM_URL env var or https://api.primitif.ai. max_retries: Number of automatic retries on transient errors. Defaults to 2. timeout: HTTP request timeout in seconds. Defaults to 30.0.

Raises: PrimitifError: If no API key is provided and PRIMITIF_API_KEY is not set.

Example:

with Primitif() as p: ... mb = p.mail.create_mailbox(name="agent", ttl=3600) ... mb.send(to="user@example.com", subject="Hi", body_text="Hello") ... mb.delete()

mail
approval
audit
namespaces
def close(self) -> None:
146    def close(self) -> None:
147        """Close all HTTP clients.
148
149        Releases the underlying connection pools for Mail, Approval, Audit,
150        and Platform APIs. Called automatically when used as a context manager.
151
152        Example:
153            >>> p = Primitif()
154            >>> # ... use p ...
155            >>> p.close()
156        """
157        self._mail_client.close()
158        self._approval_client.close()
159        self._audit_client.close()
160        self._platform_client.close()

Close all HTTP clients.

Releases the underlying connection pools for Mail, Approval, Audit, and Platform APIs. Called automatically when used as a context manager.

Example:

p = Primitif()

... use p ...

p.close()

class Mailbox:
 63class Mailbox:
 64    """A single ephemeral mailbox — send, receive, and manage email.
 65
 66    Typically created via ``p.mail.create_mailbox()``, but can also
 67    be instantiated directly with a token for agent-scoped operations.
 68
 69    Usage::
 70
 71        with Mailbox() as mb:  # reads PRIMITIF_MAIL_TOKEN from env
 72            mb.send(to="user@example.com", subject="Hi", body_text="Hello")
 73            for msg in mb.inbox():
 74                detail = mb.read(msg.id)
 75            mb.delete()
 76    """
 77
 78    def __init__(
 79        self,
 80        token: str | None = None,
 81        *,
 82        base_url: str | None = None,
 83        max_retries: int = DEFAULT_MAX_RETRIES,
 84        timeout: float = 30.0,
 85        _client: httpx.Client | None = None,
 86    ) -> None:
 87        """Create a Mailbox client for agent-scoped email operations.
 88
 89        Args:
 90            token: Mailbox token (``mb_tok_...``). Falls back to the
 91                ``PRIMITIF_MAIL_TOKEN`` environment variable if omitted.
 92            base_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL``
 93                env var or ``https://api.mail.primitif.ai``.
 94            max_retries: Number of automatic retries on transient errors.
 95                Defaults to 2.
 96            timeout: HTTP request timeout in seconds. Defaults to 30.0.
 97
 98        Raises:
 99            MailError: If no token is provided and ``PRIMITIF_MAIL_TOKEN``
100                is not set.
101
102        Example:
103            >>> mb = Mailbox(token="mb_tok_abc123")
104            >>> print(mb.address)
105            'agent-7f3a@mail.primitif.ai'
106        """
107        self._destroyed = False
108        self._token: str | None = None
109
110        if _client is not None:
111            # Internal: created by MailAdmin.create_mailbox()
112            self._client = _client
113            self._max_retries = max_retries
114            self._info_cache: MailboxInfo | None = None
115            self._owns_client = False
116            return
117
118        from primitif._version import __version__
119
120        if token is None:
121            token = os.environ.get("PRIMITIF_MAIL_TOKEN")
122        if token is not None:
123            token = token.strip()
124        if not token:
125            raise MailError(
126                message="No token provided. Pass token= or set PRIMITIF_MAIL_TOKEN env var."
127            )
128
129        self._token = token
130
131        if base_url is None:
132            base_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_BASE_URL)
133
134        self._max_retries = max_retries
135        self._info_cache = None
136        self._owns_client = True
137        self._client = httpx.Client(
138            base_url=base_url.rstrip("/"),
139            headers={
140                "Authorization": f"Bearer {token}",
141                "User-Agent": f"primitif/{__version__}",
142            },
143            timeout=timeout,
144        )
145
146    def _request(self, method: str, path: str, **kwargs) -> dict:
147        if self._destroyed:
148            raise MailError(message="Mailbox has been destroyed.")
149        return request_with_retries(
150            self._client,
151            method,
152            path,
153            self._max_retries,
154            exc_map=_EXC_MAP,
155            server_exc=MailServerError,
156            base_exc=MailError,
157            connection_exc=MailNetworkError,
158            logger=logger,
159            **kwargs,
160        )
161
162    # ── Info ──
163
164    @property
165    def address(self) -> str:
166        """The mailbox email address (fetched and cached).
167
168        Calls ``info()`` on first access to populate the cache.
169
170        Returns:
171            The full email address (e.g. ``"agent-7f3a@mail.primitif.ai"``).
172
173        Example:
174            >>> print(mb.address)
175            'agent-7f3a@mail.primitif.ai'
176        """
177        if self._info_cache is None:
178            self.info()
179        return self._info_cache.address
180
181    @property
182    def token(self) -> str | None:
183        """The mailbox token, if available.
184
185        Present when created via ``p.mail.create_mailbox()`` or when
186        ``token=`` was passed to the constructor. Useful for persisting
187        tokens so a crashed agent can reconnect with ``Mailbox(token=...)``.
188
189        Returns:
190            The token string (``mb_tok_...``) or None if not available.
191
192        Example:
193            >>> token = mb.token
194            >>> # persist token, then later:
195            >>> mb = Mailbox(token=token)
196        """
197        return self._token
198
199    def info(self) -> MailboxInfo:
200        """Get mailbox details.
201
202        Returns:
203            MailboxInfo with ``id``, ``address``, ``name``, ``expires_at``,
204            and ``created_at`` fields.
205
206        Raises:
207            MailAuthError: If the token is invalid or expired.
208            MailNetworkError: On connection failure.
209
210        Example:
211            >>> info = mb.info()
212            >>> print(info.address, info.expires_at)
213        """
214        data = self._request("GET", "/v1/me")
215        result = MailboxInfo(**data)
216        self._info_cache = result
217        return result
218
219    # ── Inbox & messages ──
220
221    def inbox(
222        self,
223        *,
224        unread_only: bool = False,
225        limit: int = 20,
226        cursor: str | None = None,
227    ) -> PaginatedList[InboxMessage]:
228        """List inbox messages.
229
230        Args:
231            unread_only: If True, return only unread messages. Defaults to False.
232            limit: Maximum number of messages per page. Defaults to 20.
233            cursor: Pagination cursor from a previous response.
234
235        Returns:
236            PaginatedList[InboxMessage] supporting iteration and
237            ``.auto_paging_iter()`` for automatic pagination.
238
239        Raises:
240            MailAuthError: If the token is invalid.
241            MailNetworkError: On connection failure.
242
243        Example:
244            >>> for msg in mb.inbox(unread_only=True):
245            ...     print(msg.from_address, msg.subject)
246            >>> # Auto-paginate through all messages:
247            >>> for msg in mb.inbox().auto_paging_iter():
248            ...     print(msg.id)
249        """
250        params: dict = {"limit": limit}
251        if unread_only:
252            params["unread_only"] = "true"
253        if cursor is not None:
254            params["cursor"] = cursor
255        data = self._request("GET", "/v1/me/inbox", params=params)
256        items = [InboxMessage(**m) for m in data["items"]]
257        result = PaginatedList(
258            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
259        )
260        result._fetch_fn = self.inbox
261        result._fetch_kwargs = {"unread_only": unread_only, "limit": limit}
262        return result
263
264    def read(self, message_id: str) -> MessageDetail:
265        """Read a full message.
266
267        Marks the message as read on the server.
268
269        Args:
270            message_id: The message ID (from InboxMessage.id).
271
272        Returns:
273            MessageDetail with full body, headers, and auth results
274            (``spf``, ``dkim``, ``dmarc``).
275
276        Raises:
277            MailNotFoundError: If the message does not exist.
278            MailAuthError: If the token is invalid.
279
280        Example:
281            >>> detail = mb.read("msg_abc123")
282            >>> print(detail.body_text)
283        """
284        data = self._request("GET", f"/v1/me/messages/{message_id}")
285        return MessageDetail(**data)
286
287    def threads(
288        self,
289        *,
290        limit: int = 20,
291        cursor: str | None = None,
292    ) -> PaginatedList[ThreadSummary]:
293        """List email threads.
294
295        Args:
296            limit: Maximum number of threads per page. Defaults to 20.
297            cursor: Pagination cursor from a previous response.
298
299        Returns:
300            PaginatedList[ThreadSummary] supporting iteration and
301            ``.auto_paging_iter()``.
302
303        Raises:
304            MailAuthError: If the token is invalid.
305            MailNetworkError: On connection failure.
306
307        Example:
308            >>> for t in mb.threads():
309            ...     print(t.subject, t.message_count)
310        """
311        params: dict = {"limit": limit}
312        if cursor is not None:
313            params["cursor"] = cursor
314        data = self._request("GET", "/v1/me/threads", params=params)
315        items = [ThreadSummary(**t) for t in data["items"]]
316        result = PaginatedList(
317            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
318        )
319        result._fetch_fn = self.threads
320        result._fetch_kwargs = {"limit": limit}
321        return result
322
323    def thread(self, thread_id: str) -> ThreadDetail:
324        """Get a full thread with all messages.
325
326        Args:
327            thread_id: The thread ID.
328
329        Returns:
330            ThreadDetail containing the thread metadata and a ``messages``
331            list of MessageDetail objects.
332
333        Raises:
334            MailNotFoundError: If the thread does not exist.
335            MailAuthError: If the token is invalid.
336
337        Example:
338            >>> t = mb.thread("thr_abc123")
339            >>> for msg in t.messages:
340            ...     print(msg.from_address, msg.subject)
341        """
342        data = self._request("GET", f"/v1/me/threads/{thread_id}")
343        messages = [MessageDetail(**m) for m in data.pop("messages", [])]
344        return ThreadDetail(**data, messages=messages)
345
346    # ── Sending ──
347
348    def send(
349        self,
350        *,
351        to: str,
352        subject: str,
353        body_text: str,
354        body_html: str | None = None,
355        require_approval: bool = False,
356    ) -> SendResult:
357        """Send an email from this mailbox.
358
359        Args:
360            to: Recipient email address.
361            subject: Email subject line.
362            body_text: Plain-text email body.
363            body_html: Optional HTML email body.
364            require_approval: If True, the email is held for human approval
365                before sending. Defaults to False.
366
367        Returns:
368            SendResult with ``thread_id``, ``message_id``, and ``status``
369            (``"sent"``, ``"held"``, or ``"pending_approval"``). When
370            approval is required, ``approval_url`` is set.
371
372        Raises:
373            MailValidationError: If the payload is invalid (e.g. bad address).
374            MailAuthError: If the token is invalid.
375
376        Example:
377            >>> result = mb.send(
378            ...     to="user@example.com",
379            ...     subject="Invoice #42",
380            ...     body_text="Please find your invoice attached.",
381            ... )
382            >>> print(result.message_id)
383        """
384        payload: dict = {"to": to, "subject": subject, "body_text": body_text}
385        if body_html is not None:
386            payload["body_html"] = body_html
387        if require_approval:
388            payload["require_approval"] = True
389        data = self._request("POST", "/v1/me/send", json=payload)
390        return SendResult(**data)
391
392    def reply(
393        self,
394        message_id: str,
395        *,
396        body_text: str,
397        body_html: str | None = None,
398        require_approval: bool = False,
399    ) -> SendResult:
400        """Reply to a message.
401
402        Sends a reply in the same thread as the original message.
403
404        Args:
405            message_id: The ID of the message to reply to.
406            body_text: Plain-text reply body.
407            body_html: Optional HTML reply body.
408            require_approval: If True, the reply is held for human approval.
409                Defaults to False.
410
411        Returns:
412            SendResult with ``thread_id``, ``message_id``, and ``status``.
413
414        Raises:
415            MailNotFoundError: If the original message does not exist.
416            MailValidationError: If the payload is invalid.
417            MailAuthError: If the token is invalid.
418
419        Example:
420            >>> for msg in mb.inbox(unread_only=True):
421            ...     mb.reply(msg.id, body_text="Got it, thanks!")
422        """
423        payload: dict = {"body_text": body_text}
424        if body_html is not None:
425            payload["body_html"] = body_html
426        if require_approval:
427            payload["require_approval"] = True
428        data = self._request("POST", f"/v1/me/reply/{message_id}", json=payload)
429        return SendResult(**data)
430
431    # ── Attachments ──
432
433    def list_attachments(self, message_id: str) -> list[AttachmentInfo]:
434        """List attachments on a message.
435
436        Args:
437            message_id: The message ID.
438
439        Returns:
440            List of AttachmentInfo with ``id``, ``filename``,
441            ``content_type``, and ``size_bytes``.
442
443        Raises:
444            MailNotFoundError: If the message does not exist.
445            MailAuthError: If the token is invalid.
446
447        Example:
448            >>> attachments = mb.list_attachments("msg_abc123")
449            >>> for a in attachments:
450            ...     print(a.filename, a.size_bytes)
451        """
452        data = self._request("GET", f"/v1/me/messages/{message_id}/attachments")
453        return [AttachmentInfo(**a) for a in data["items"]]
454
455    def download_attachment(self, message_id: str, attachment_id: str) -> bytes:
456        """Download a raw attachment.
457
458        Args:
459            message_id: The message ID containing the attachment.
460            attachment_id: The attachment ID (from AttachmentInfo.id).
461
462        Returns:
463            Raw file bytes.
464
465        Raises:
466            MailNotFoundError: If the message or attachment does not exist.
467            MailAuthError: If the token is invalid.
468            MailNetworkError: On connection failure after retries.
469
470        Example:
471            >>> data = mb.download_attachment("msg_abc123", "att_def456")
472            >>> with open("invoice.pdf", "wb") as f:
473            ...     f.write(data)
474        """
475        if self._destroyed:
476            raise MailError(message="Mailbox has been destroyed.")
477        path = f"/v1/me/messages/{message_id}/attachments/{attachment_id}"
478        last_response: httpx.Response | None = None
479        last_exc: httpx.TransportError | None = None
480        for attempt in range(self._max_retries + 1):
481            try:
482                response = self._client.request("GET", path)
483            except (httpx.TimeoutException, httpx.ConnectError) as exc:
484                last_exc = exc
485                if attempt < self._max_retries:
486                    _time.sleep(_transport_retry_delay(attempt))
487                    continue
488                raise MailNetworkError(
489                    message=f"{type(exc).__name__}: {exc}",
490                ) from exc
491            except httpx.TransportError as exc:
492                raise MailNetworkError(
493                    message=f"{type(exc).__name__}: {exc}",
494                ) from exc
495            last_exc = None
496            last_response = response
497            if response.status_code in RETRY_STATUS_CODES and attempt < self._max_retries:
498                _time.sleep(_retry_delay(response, attempt))
499                continue
500            break
501        if last_response is None:
502            raise MailNetworkError(
503                message=f"{type(last_exc).__name__}: {last_exc}",
504            ) from last_exc
505        raise_for_status(
506            last_response,
507            exc_map=_EXC_MAP,
508            server_exc=MailServerError,
509            base_exc=MailError,
510        )
511        return last_response.content
512
513    # ── Allowlist ──
514
515    def get_allowlist(self) -> list[AllowlistEntry]:
516        """List allowed senders.
517
518        Returns:
519            List of AllowlistEntry with ``id``, ``sender_address``, and
520            ``created_at``.
521
522        Raises:
523            MailAuthError: If the token is invalid.
524
525        Example:
526            >>> for entry in mb.get_allowlist():
527            ...     print(entry.sender_address)
528        """
529        data = self._request("GET", "/v1/me/allowlist")
530        return [AllowlistEntry(**e) for e in data["items"]]
531
532    def add_allowlist(self, sender_address: str) -> AllowlistEntry:
533        """Add an allowed sender.
534
535        Only emails from allowlisted senders will be delivered to this
536        mailbox (when the allowlist is non-empty).
537
538        Args:
539            sender_address: Email address to allow (e.g. ``"noreply@github.com"``).
540
541        Returns:
542            The created AllowlistEntry.
543
544        Raises:
545            MailConflictError: If the address is already allowlisted.
546            MailValidationError: If the address is invalid.
547            MailAuthError: If the token is invalid.
548
549        Example:
550            >>> entry = mb.add_allowlist("noreply@github.com")
551            >>> print(entry.id)
552        """
553        data = self._request(
554            "POST", "/v1/me/allowlist", json={"sender_address": sender_address}
555        )
556        return AllowlistEntry(**data)
557
558    def remove_allowlist(self, entry_id: str) -> None:
559        """Remove an allowed sender.
560
561        Args:
562            entry_id: The allowlist entry ID (from AllowlistEntry.id).
563
564        Raises:
565            MailNotFoundError: If the entry does not exist.
566            MailAuthError: If the token is invalid.
567
568        Example:
569            >>> mb.remove_allowlist("ale_abc123")
570        """
571        self._request("DELETE", f"/v1/me/allowlist/{entry_id}")
572
573    # ── Auth policy ──
574
575    def get_auth_policy(self) -> str:
576        """Get the email authentication policy.
577
578        Returns:
579            The current policy as a string: ``"none"``, ``"warn"``, or
580            ``"reject"``.
581
582        Raises:
583            MailAuthError: If the token is invalid.
584
585        Example:
586            >>> policy = mb.get_auth_policy()
587            >>> print(policy)  # "none", "warn", or "reject"
588        """
589        data = self._request("GET", "/v1/me/auth-policy")
590        return data["auth_policy"]
591
592    def set_auth_policy(self, policy: Literal["none", "warn", "reject"]) -> None:
593        """Set the email authentication policy.
594
595        Controls how the mailbox handles emails that fail SPF/DKIM/DMARC
596        checks.
597
598        Args:
599            policy: One of ``"none"`` (accept all), ``"warn"`` (accept but
600                flag), or ``"reject"`` (reject failing emails).
601
602        Raises:
603            MailValidationError: If the policy value is invalid.
604            MailAuthError: If the token is invalid.
605
606        Example:
607            >>> mb.set_auth_policy("reject")
608        """
609        self._request("PUT", "/v1/me/auth-policy", json={"auth_policy": policy})
610
611    # ── Webhooks ──
612
613    def set_webhook(self, url: str, events: list[str] | None = None) -> WebhookCreated:
614        """Register a webhook. Returns WebhookCreated with signing secret (shown once).
615
616        Replaces any existing webhook on this mailbox.
617
618        Args:
619            url: The HTTPS URL to receive webhook POST requests.
620            events: List of event types to subscribe to (e.g.
621                ``["message.received"]``). Omit to subscribe to all events.
622
623        Returns:
624            WebhookCreated with ``id``, ``url``, ``secret`` (one-time),
625            and ``events``.
626
627        Raises:
628            MailValidationError: If the URL is invalid.
629            MailAuthError: If the token is invalid.
630
631        Example:
632            >>> wh = mb.set_webhook("https://example.com/hook")
633            >>> print(wh.secret)  # save this -- shown only once
634        """
635        payload: dict = {"url": url}
636        if events is not None:
637            payload["events"] = events
638        data = self._request("POST", "/v1/me/webhook", json=payload)
639        return WebhookCreated(**data)
640
641    def webhook(self) -> WebhookInfo:
642        """Get current webhook config.
643
644        Returns:
645            WebhookInfo with ``id``, ``url``, ``events``, and ``created_at``.
646
647        Raises:
648            MailNotFoundError: If no webhook is configured.
649            MailAuthError: If the token is invalid.
650
651        Example:
652            >>> wh = mb.webhook()
653            >>> print(wh.url, wh.events)
654        """
655        data = self._request("GET", "/v1/me/webhook")
656        return WebhookInfo(**data)
657
658    def delete_webhook(self) -> None:
659        """Remove the webhook.
660
661        Raises:
662            MailNotFoundError: If no webhook is configured.
663            MailAuthError: If the token is invalid.
664
665        Example:
666            >>> mb.delete_webhook()
667        """
668        self._request("DELETE", "/v1/me/webhook")
669
670    # ── Lifecycle ──
671
672    def delete(self) -> None:
673        """Delete this mailbox from the server and close the HTTP client.
674
675        This permanently destroys the mailbox and all its messages. The
676        Mailbox instance cannot be used after this call.
677
678        Raises:
679            MailAuthError: If the token is invalid.
680            MailNetworkError: On connection failure.
681
682        Example:
683            >>> mb.delete()  # mailbox is gone
684        """
685        self._request("DELETE", "/v1/me")
686        self._destroyed = True
687        if self._owns_client:
688            self._client.close()
689
690    def close(self) -> None:
691        """Close the HTTP client.
692
693        The mailbox remains active on the server. Use ``delete()`` to
694        destroy it entirely. Called automatically when used as a context
695        manager.
696
697        Example:
698            >>> mb.close()
699        """
700        if self._owns_client:
701            self._client.close()
702
703    def __enter__(self) -> Mailbox:
704        return self
705
706    def __exit__(self, *args) -> None:
707        self.close()
708
709    def __repr__(self) -> str:
710        if self._info_cache:
711            return f"Mailbox({self._info_cache.address})"
712        return "Mailbox()"

A single ephemeral mailbox — send, receive, and manage email.

Typically created via p.mail.create_mailbox(), but can also be instantiated directly with a token for agent-scoped operations.

Usage::

with Mailbox() as mb:  # reads PRIMITIF_MAIL_TOKEN from env
    mb.send(to="user@example.com", subject="Hi", body_text="Hello")
    for msg in mb.inbox():
        detail = mb.read(msg.id)
    mb.delete()
Mailbox( token: str | None = None, *, base_url: str | None = None, max_retries: int = 2, timeout: float = 30.0, _client: httpx.Client | None = None)
 78    def __init__(
 79        self,
 80        token: str | None = None,
 81        *,
 82        base_url: str | None = None,
 83        max_retries: int = DEFAULT_MAX_RETRIES,
 84        timeout: float = 30.0,
 85        _client: httpx.Client | None = None,
 86    ) -> None:
 87        """Create a Mailbox client for agent-scoped email operations.
 88
 89        Args:
 90            token: Mailbox token (``mb_tok_...``). Falls back to the
 91                ``PRIMITIF_MAIL_TOKEN`` environment variable if omitted.
 92            base_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL``
 93                env var or ``https://api.mail.primitif.ai``.
 94            max_retries: Number of automatic retries on transient errors.
 95                Defaults to 2.
 96            timeout: HTTP request timeout in seconds. Defaults to 30.0.
 97
 98        Raises:
 99            MailError: If no token is provided and ``PRIMITIF_MAIL_TOKEN``
100                is not set.
101
102        Example:
103            >>> mb = Mailbox(token="mb_tok_abc123")
104            >>> print(mb.address)
105            'agent-7f3a@mail.primitif.ai'
106        """
107        self._destroyed = False
108        self._token: str | None = None
109
110        if _client is not None:
111            # Internal: created by MailAdmin.create_mailbox()
112            self._client = _client
113            self._max_retries = max_retries
114            self._info_cache: MailboxInfo | None = None
115            self._owns_client = False
116            return
117
118        from primitif._version import __version__
119
120        if token is None:
121            token = os.environ.get("PRIMITIF_MAIL_TOKEN")
122        if token is not None:
123            token = token.strip()
124        if not token:
125            raise MailError(
126                message="No token provided. Pass token= or set PRIMITIF_MAIL_TOKEN env var."
127            )
128
129        self._token = token
130
131        if base_url is None:
132            base_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_BASE_URL)
133
134        self._max_retries = max_retries
135        self._info_cache = None
136        self._owns_client = True
137        self._client = httpx.Client(
138            base_url=base_url.rstrip("/"),
139            headers={
140                "Authorization": f"Bearer {token}",
141                "User-Agent": f"primitif/{__version__}",
142            },
143            timeout=timeout,
144        )

Create a Mailbox client for agent-scoped email operations.

Args: token: Mailbox token (mb_tok_...). Falls back to the PRIMITIF_MAIL_TOKEN environment variable if omitted. base_url: Mail API base URL. Defaults to PRIMITIF_MAIL_URL env var or https://api.mail.primitif.ai. max_retries: Number of automatic retries on transient errors. Defaults to 2. timeout: HTTP request timeout in seconds. Defaults to 30.0.

Raises: MailError: If no token is provided and PRIMITIF_MAIL_TOKEN is not set.

Example:

mb = Mailbox(token="mb_tok_abc123") print(mb.address) 'agent-7f3a@mail.primitif.ai'

address: str
164    @property
165    def address(self) -> str:
166        """The mailbox email address (fetched and cached).
167
168        Calls ``info()`` on first access to populate the cache.
169
170        Returns:
171            The full email address (e.g. ``"agent-7f3a@mail.primitif.ai"``).
172
173        Example:
174            >>> print(mb.address)
175            'agent-7f3a@mail.primitif.ai'
176        """
177        if self._info_cache is None:
178            self.info()
179        return self._info_cache.address

The mailbox email address (fetched and cached).

Calls info() on first access to populate the cache.

Returns: The full email address (e.g. "agent-7f3a@mail.primitif.ai").

Example:

print(mb.address) 'agent-7f3a@mail.primitif.ai'

token: str | None
181    @property
182    def token(self) -> str | None:
183        """The mailbox token, if available.
184
185        Present when created via ``p.mail.create_mailbox()`` or when
186        ``token=`` was passed to the constructor. Useful for persisting
187        tokens so a crashed agent can reconnect with ``Mailbox(token=...)``.
188
189        Returns:
190            The token string (``mb_tok_...``) or None if not available.
191
192        Example:
193            >>> token = mb.token
194            >>> # persist token, then later:
195            >>> mb = Mailbox(token=token)
196        """
197        return self._token

The mailbox token, if available.

Present when created via p.mail.create_mailbox() or when token= was passed to the constructor. Useful for persisting tokens so a crashed agent can reconnect with Mailbox(token=...).

Returns: The token string (mb_tok_...) or None if not available.

Example:

token = mb.token

persist token, then later:

mb = Mailbox(token=token)

def info(self) -> primitif.mail.models.MailboxInfo:
199    def info(self) -> MailboxInfo:
200        """Get mailbox details.
201
202        Returns:
203            MailboxInfo with ``id``, ``address``, ``name``, ``expires_at``,
204            and ``created_at`` fields.
205
206        Raises:
207            MailAuthError: If the token is invalid or expired.
208            MailNetworkError: On connection failure.
209
210        Example:
211            >>> info = mb.info()
212            >>> print(info.address, info.expires_at)
213        """
214        data = self._request("GET", "/v1/me")
215        result = MailboxInfo(**data)
216        self._info_cache = result
217        return result

Get mailbox details.

Returns: MailboxInfo with id, address, name, expires_at, and created_at fields.

Raises: MailAuthError: If the token is invalid or expired. MailNetworkError: On connection failure.

Example:

info = mb.info() print(info.address, info.expires_at)

def inbox( self, *, unread_only: bool = False, limit: int = 20, cursor: str | None = None) -> PaginatedList[InboxMessage]:
221    def inbox(
222        self,
223        *,
224        unread_only: bool = False,
225        limit: int = 20,
226        cursor: str | None = None,
227    ) -> PaginatedList[InboxMessage]:
228        """List inbox messages.
229
230        Args:
231            unread_only: If True, return only unread messages. Defaults to False.
232            limit: Maximum number of messages per page. Defaults to 20.
233            cursor: Pagination cursor from a previous response.
234
235        Returns:
236            PaginatedList[InboxMessage] supporting iteration and
237            ``.auto_paging_iter()`` for automatic pagination.
238
239        Raises:
240            MailAuthError: If the token is invalid.
241            MailNetworkError: On connection failure.
242
243        Example:
244            >>> for msg in mb.inbox(unread_only=True):
245            ...     print(msg.from_address, msg.subject)
246            >>> # Auto-paginate through all messages:
247            >>> for msg in mb.inbox().auto_paging_iter():
248            ...     print(msg.id)
249        """
250        params: dict = {"limit": limit}
251        if unread_only:
252            params["unread_only"] = "true"
253        if cursor is not None:
254            params["cursor"] = cursor
255        data = self._request("GET", "/v1/me/inbox", params=params)
256        items = [InboxMessage(**m) for m in data["items"]]
257        result = PaginatedList(
258            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
259        )
260        result._fetch_fn = self.inbox
261        result._fetch_kwargs = {"unread_only": unread_only, "limit": limit}
262        return result

List inbox messages.

Args: unread_only: If True, return only unread messages. Defaults to False. limit: Maximum number of messages per page. Defaults to 20. cursor: Pagination cursor from a previous response.

Returns: PaginatedList[InboxMessage] supporting iteration and .auto_paging_iter() for automatic pagination.

Raises: MailAuthError: If the token is invalid. MailNetworkError: On connection failure.

Example:

for msg in mb.inbox(unread_only=True): ... print(msg.from_address, msg.subject)

Auto-paginate through all messages:

for msg in mb.inbox().auto_paging_iter(): ... print(msg.id)

def read(self, message_id: str) -> primitif.mail.models.MessageDetail:
264    def read(self, message_id: str) -> MessageDetail:
265        """Read a full message.
266
267        Marks the message as read on the server.
268
269        Args:
270            message_id: The message ID (from InboxMessage.id).
271
272        Returns:
273            MessageDetail with full body, headers, and auth results
274            (``spf``, ``dkim``, ``dmarc``).
275
276        Raises:
277            MailNotFoundError: If the message does not exist.
278            MailAuthError: If the token is invalid.
279
280        Example:
281            >>> detail = mb.read("msg_abc123")
282            >>> print(detail.body_text)
283        """
284        data = self._request("GET", f"/v1/me/messages/{message_id}")
285        return MessageDetail(**data)

Read a full message.

Marks the message as read on the server.

Args: message_id: The message ID (from InboxMessage.id).

Returns: MessageDetail with full body, headers, and auth results (spf, dkim, dmarc).

Raises: MailNotFoundError: If the message does not exist. MailAuthError: If the token is invalid.

Example:

detail = mb.read("msg_abc123") print(detail.body_text)

def threads( self, *, limit: int = 20, cursor: str | None = None) -> PaginatedList[ThreadSummary]:
287    def threads(
288        self,
289        *,
290        limit: int = 20,
291        cursor: str | None = None,
292    ) -> PaginatedList[ThreadSummary]:
293        """List email threads.
294
295        Args:
296            limit: Maximum number of threads per page. Defaults to 20.
297            cursor: Pagination cursor from a previous response.
298
299        Returns:
300            PaginatedList[ThreadSummary] supporting iteration and
301            ``.auto_paging_iter()``.
302
303        Raises:
304            MailAuthError: If the token is invalid.
305            MailNetworkError: On connection failure.
306
307        Example:
308            >>> for t in mb.threads():
309            ...     print(t.subject, t.message_count)
310        """
311        params: dict = {"limit": limit}
312        if cursor is not None:
313            params["cursor"] = cursor
314        data = self._request("GET", "/v1/me/threads", params=params)
315        items = [ThreadSummary(**t) for t in data["items"]]
316        result = PaginatedList(
317            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
318        )
319        result._fetch_fn = self.threads
320        result._fetch_kwargs = {"limit": limit}
321        return result

List email threads.

Args: limit: Maximum number of threads per page. Defaults to 20. cursor: Pagination cursor from a previous response.

Returns: PaginatedList[ThreadSummary] supporting iteration and .auto_paging_iter().

Raises: MailAuthError: If the token is invalid. MailNetworkError: On connection failure.

Example:

for t in mb.threads(): ... print(t.subject, t.message_count)

def thread(self, thread_id: str) -> primitif.mail.models.ThreadDetail:
323    def thread(self, thread_id: str) -> ThreadDetail:
324        """Get a full thread with all messages.
325
326        Args:
327            thread_id: The thread ID.
328
329        Returns:
330            ThreadDetail containing the thread metadata and a ``messages``
331            list of MessageDetail objects.
332
333        Raises:
334            MailNotFoundError: If the thread does not exist.
335            MailAuthError: If the token is invalid.
336
337        Example:
338            >>> t = mb.thread("thr_abc123")
339            >>> for msg in t.messages:
340            ...     print(msg.from_address, msg.subject)
341        """
342        data = self._request("GET", f"/v1/me/threads/{thread_id}")
343        messages = [MessageDetail(**m) for m in data.pop("messages", [])]
344        return ThreadDetail(**data, messages=messages)

Get a full thread with all messages.

Args: thread_id: The thread ID.

Returns: ThreadDetail containing the thread metadata and a messages list of MessageDetail objects.

Raises: MailNotFoundError: If the thread does not exist. MailAuthError: If the token is invalid.

Example:

t = mb.thread("thr_abc123") for msg in t.messages: ... print(msg.from_address, msg.subject)

def send( self, *, to: str, subject: str, body_text: str, body_html: str | None = None, require_approval: bool = False) -> primitif.mail.models.SendResult:
348    def send(
349        self,
350        *,
351        to: str,
352        subject: str,
353        body_text: str,
354        body_html: str | None = None,
355        require_approval: bool = False,
356    ) -> SendResult:
357        """Send an email from this mailbox.
358
359        Args:
360            to: Recipient email address.
361            subject: Email subject line.
362            body_text: Plain-text email body.
363            body_html: Optional HTML email body.
364            require_approval: If True, the email is held for human approval
365                before sending. Defaults to False.
366
367        Returns:
368            SendResult with ``thread_id``, ``message_id``, and ``status``
369            (``"sent"``, ``"held"``, or ``"pending_approval"``). When
370            approval is required, ``approval_url`` is set.
371
372        Raises:
373            MailValidationError: If the payload is invalid (e.g. bad address).
374            MailAuthError: If the token is invalid.
375
376        Example:
377            >>> result = mb.send(
378            ...     to="user@example.com",
379            ...     subject="Invoice #42",
380            ...     body_text="Please find your invoice attached.",
381            ... )
382            >>> print(result.message_id)
383        """
384        payload: dict = {"to": to, "subject": subject, "body_text": body_text}
385        if body_html is not None:
386            payload["body_html"] = body_html
387        if require_approval:
388            payload["require_approval"] = True
389        data = self._request("POST", "/v1/me/send", json=payload)
390        return SendResult(**data)

Send an email from this mailbox.

Args: to: Recipient email address. subject: Email subject line. body_text: Plain-text email body. body_html: Optional HTML email body. require_approval: If True, the email is held for human approval before sending. Defaults to False.

Returns: SendResult with thread_id, message_id, and status ("sent", "held", or "pending_approval"). When approval is required, approval_url is set.

Raises: MailValidationError: If the payload is invalid (e.g. bad address). MailAuthError: If the token is invalid.

Example:

result = mb.send( ... to="user@example.com", ... subject="Invoice #42", ... body_text="Please find your invoice attached.", ... ) print(result.message_id)

def reply( self, message_id: str, *, body_text: str, body_html: str | None = None, require_approval: bool = False) -> primitif.mail.models.SendResult:
392    def reply(
393        self,
394        message_id: str,
395        *,
396        body_text: str,
397        body_html: str | None = None,
398        require_approval: bool = False,
399    ) -> SendResult:
400        """Reply to a message.
401
402        Sends a reply in the same thread as the original message.
403
404        Args:
405            message_id: The ID of the message to reply to.
406            body_text: Plain-text reply body.
407            body_html: Optional HTML reply body.
408            require_approval: If True, the reply is held for human approval.
409                Defaults to False.
410
411        Returns:
412            SendResult with ``thread_id``, ``message_id``, and ``status``.
413
414        Raises:
415            MailNotFoundError: If the original message does not exist.
416            MailValidationError: If the payload is invalid.
417            MailAuthError: If the token is invalid.
418
419        Example:
420            >>> for msg in mb.inbox(unread_only=True):
421            ...     mb.reply(msg.id, body_text="Got it, thanks!")
422        """
423        payload: dict = {"body_text": body_text}
424        if body_html is not None:
425            payload["body_html"] = body_html
426        if require_approval:
427            payload["require_approval"] = True
428        data = self._request("POST", f"/v1/me/reply/{message_id}", json=payload)
429        return SendResult(**data)

Reply to a message.

Sends a reply in the same thread as the original message.

Args: message_id: The ID of the message to reply to. body_text: Plain-text reply body. body_html: Optional HTML reply body. require_approval: If True, the reply is held for human approval. Defaults to False.

Returns: SendResult with thread_id, message_id, and status.

Raises: MailNotFoundError: If the original message does not exist. MailValidationError: If the payload is invalid. MailAuthError: If the token is invalid.

Example:

for msg in mb.inbox(unread_only=True): ... mb.reply(msg.id, body_text="Got it, thanks!")

def list_attachments(self, message_id: str) -> list[primitif.mail.models.AttachmentInfo]:
433    def list_attachments(self, message_id: str) -> list[AttachmentInfo]:
434        """List attachments on a message.
435
436        Args:
437            message_id: The message ID.
438
439        Returns:
440            List of AttachmentInfo with ``id``, ``filename``,
441            ``content_type``, and ``size_bytes``.
442
443        Raises:
444            MailNotFoundError: If the message does not exist.
445            MailAuthError: If the token is invalid.
446
447        Example:
448            >>> attachments = mb.list_attachments("msg_abc123")
449            >>> for a in attachments:
450            ...     print(a.filename, a.size_bytes)
451        """
452        data = self._request("GET", f"/v1/me/messages/{message_id}/attachments")
453        return [AttachmentInfo(**a) for a in data["items"]]

List attachments on a message.

Args: message_id: The message ID.

Returns: List of AttachmentInfo with id, filename, content_type, and size_bytes.

Raises: MailNotFoundError: If the message does not exist. MailAuthError: If the token is invalid.

Example:

attachments = mb.list_attachments("msg_abc123") for a in attachments: ... print(a.filename, a.size_bytes)

def download_attachment(self, message_id: str, attachment_id: str) -> bytes:
455    def download_attachment(self, message_id: str, attachment_id: str) -> bytes:
456        """Download a raw attachment.
457
458        Args:
459            message_id: The message ID containing the attachment.
460            attachment_id: The attachment ID (from AttachmentInfo.id).
461
462        Returns:
463            Raw file bytes.
464
465        Raises:
466            MailNotFoundError: If the message or attachment does not exist.
467            MailAuthError: If the token is invalid.
468            MailNetworkError: On connection failure after retries.
469
470        Example:
471            >>> data = mb.download_attachment("msg_abc123", "att_def456")
472            >>> with open("invoice.pdf", "wb") as f:
473            ...     f.write(data)
474        """
475        if self._destroyed:
476            raise MailError(message="Mailbox has been destroyed.")
477        path = f"/v1/me/messages/{message_id}/attachments/{attachment_id}"
478        last_response: httpx.Response | None = None
479        last_exc: httpx.TransportError | None = None
480        for attempt in range(self._max_retries + 1):
481            try:
482                response = self._client.request("GET", path)
483            except (httpx.TimeoutException, httpx.ConnectError) as exc:
484                last_exc = exc
485                if attempt < self._max_retries:
486                    _time.sleep(_transport_retry_delay(attempt))
487                    continue
488                raise MailNetworkError(
489                    message=f"{type(exc).__name__}: {exc}",
490                ) from exc
491            except httpx.TransportError as exc:
492                raise MailNetworkError(
493                    message=f"{type(exc).__name__}: {exc}",
494                ) from exc
495            last_exc = None
496            last_response = response
497            if response.status_code in RETRY_STATUS_CODES and attempt < self._max_retries:
498                _time.sleep(_retry_delay(response, attempt))
499                continue
500            break
501        if last_response is None:
502            raise MailNetworkError(
503                message=f"{type(last_exc).__name__}: {last_exc}",
504            ) from last_exc
505        raise_for_status(
506            last_response,
507            exc_map=_EXC_MAP,
508            server_exc=MailServerError,
509            base_exc=MailError,
510        )
511        return last_response.content

Download a raw attachment.

Args: message_id: The message ID containing the attachment. attachment_id: The attachment ID (from AttachmentInfo.id).

Returns: Raw file bytes.

Raises: MailNotFoundError: If the message or attachment does not exist. MailAuthError: If the token is invalid. MailNetworkError: On connection failure after retries.

Example:

data = mb.download_attachment("msg_abc123", "att_def456") with open("invoice.pdf", "wb") as f: ... f.write(data)

def get_allowlist(self) -> list[primitif.mail.models.AllowlistEntry]:
515    def get_allowlist(self) -> list[AllowlistEntry]:
516        """List allowed senders.
517
518        Returns:
519            List of AllowlistEntry with ``id``, ``sender_address``, and
520            ``created_at``.
521
522        Raises:
523            MailAuthError: If the token is invalid.
524
525        Example:
526            >>> for entry in mb.get_allowlist():
527            ...     print(entry.sender_address)
528        """
529        data = self._request("GET", "/v1/me/allowlist")
530        return [AllowlistEntry(**e) for e in data["items"]]

List allowed senders.

Returns: List of AllowlistEntry with id, sender_address, and created_at.

Raises: MailAuthError: If the token is invalid.

Example:

for entry in mb.get_allowlist(): ... print(entry.sender_address)

def add_allowlist(self, sender_address: str) -> primitif.mail.models.AllowlistEntry:
532    def add_allowlist(self, sender_address: str) -> AllowlistEntry:
533        """Add an allowed sender.
534
535        Only emails from allowlisted senders will be delivered to this
536        mailbox (when the allowlist is non-empty).
537
538        Args:
539            sender_address: Email address to allow (e.g. ``"noreply@github.com"``).
540
541        Returns:
542            The created AllowlistEntry.
543
544        Raises:
545            MailConflictError: If the address is already allowlisted.
546            MailValidationError: If the address is invalid.
547            MailAuthError: If the token is invalid.
548
549        Example:
550            >>> entry = mb.add_allowlist("noreply@github.com")
551            >>> print(entry.id)
552        """
553        data = self._request(
554            "POST", "/v1/me/allowlist", json={"sender_address": sender_address}
555        )
556        return AllowlistEntry(**data)

Add an allowed sender.

Only emails from allowlisted senders will be delivered to this mailbox (when the allowlist is non-empty).

Args: sender_address: Email address to allow (e.g. "noreply@github.com").

Returns: The created AllowlistEntry.

Raises: MailConflictError: If the address is already allowlisted. MailValidationError: If the address is invalid. MailAuthError: If the token is invalid.

Example:

entry = mb.add_allowlist("noreply@github.com") print(entry.id)

def remove_allowlist(self, entry_id: str) -> None:
558    def remove_allowlist(self, entry_id: str) -> None:
559        """Remove an allowed sender.
560
561        Args:
562            entry_id: The allowlist entry ID (from AllowlistEntry.id).
563
564        Raises:
565            MailNotFoundError: If the entry does not exist.
566            MailAuthError: If the token is invalid.
567
568        Example:
569            >>> mb.remove_allowlist("ale_abc123")
570        """
571        self._request("DELETE", f"/v1/me/allowlist/{entry_id}")

Remove an allowed sender.

Args: entry_id: The allowlist entry ID (from AllowlistEntry.id).

Raises: MailNotFoundError: If the entry does not exist. MailAuthError: If the token is invalid.

Example:

mb.remove_allowlist("ale_abc123")

def get_auth_policy(self) -> str:
575    def get_auth_policy(self) -> str:
576        """Get the email authentication policy.
577
578        Returns:
579            The current policy as a string: ``"none"``, ``"warn"``, or
580            ``"reject"``.
581
582        Raises:
583            MailAuthError: If the token is invalid.
584
585        Example:
586            >>> policy = mb.get_auth_policy()
587            >>> print(policy)  # "none", "warn", or "reject"
588        """
589        data = self._request("GET", "/v1/me/auth-policy")
590        return data["auth_policy"]

Get the email authentication policy.

Returns: The current policy as a string: "none", "warn", or "reject".

Raises: MailAuthError: If the token is invalid.

Example:

policy = mb.get_auth_policy() print(policy) # "none", "warn", or "reject"

def set_auth_policy(self, policy: Literal['none', 'warn', 'reject']) -> None:
592    def set_auth_policy(self, policy: Literal["none", "warn", "reject"]) -> None:
593        """Set the email authentication policy.
594
595        Controls how the mailbox handles emails that fail SPF/DKIM/DMARC
596        checks.
597
598        Args:
599            policy: One of ``"none"`` (accept all), ``"warn"`` (accept but
600                flag), or ``"reject"`` (reject failing emails).
601
602        Raises:
603            MailValidationError: If the policy value is invalid.
604            MailAuthError: If the token is invalid.
605
606        Example:
607            >>> mb.set_auth_policy("reject")
608        """
609        self._request("PUT", "/v1/me/auth-policy", json={"auth_policy": policy})

Set the email authentication policy.

Controls how the mailbox handles emails that fail SPF/DKIM/DMARC checks.

Args: policy: One of "none" (accept all), "warn" (accept but flag), or "reject" (reject failing emails).

Raises: MailValidationError: If the policy value is invalid. MailAuthError: If the token is invalid.

Example:

mb.set_auth_policy("reject")

def set_webhook( self, url: str, events: list[str] | None = None) -> primitif.mail.models.WebhookCreated:
613    def set_webhook(self, url: str, events: list[str] | None = None) -> WebhookCreated:
614        """Register a webhook. Returns WebhookCreated with signing secret (shown once).
615
616        Replaces any existing webhook on this mailbox.
617
618        Args:
619            url: The HTTPS URL to receive webhook POST requests.
620            events: List of event types to subscribe to (e.g.
621                ``["message.received"]``). Omit to subscribe to all events.
622
623        Returns:
624            WebhookCreated with ``id``, ``url``, ``secret`` (one-time),
625            and ``events``.
626
627        Raises:
628            MailValidationError: If the URL is invalid.
629            MailAuthError: If the token is invalid.
630
631        Example:
632            >>> wh = mb.set_webhook("https://example.com/hook")
633            >>> print(wh.secret)  # save this -- shown only once
634        """
635        payload: dict = {"url": url}
636        if events is not None:
637            payload["events"] = events
638        data = self._request("POST", "/v1/me/webhook", json=payload)
639        return WebhookCreated(**data)

Register a webhook. Returns WebhookCreated with signing secret (shown once).

Replaces any existing webhook on this mailbox.

Args: url: The HTTPS URL to receive webhook POST requests. events: List of event types to subscribe to (e.g. ["message.received"]). Omit to subscribe to all events.

Returns: WebhookCreated with id, url, secret (one-time), and events.

Raises: MailValidationError: If the URL is invalid. MailAuthError: If the token is invalid.

Example:

wh = mb.set_webhook("https://example.com/hook") print(wh.secret) # save this -- shown only once

def webhook(self) -> primitif.mail.models.WebhookInfo:
641    def webhook(self) -> WebhookInfo:
642        """Get current webhook config.
643
644        Returns:
645            WebhookInfo with ``id``, ``url``, ``events``, and ``created_at``.
646
647        Raises:
648            MailNotFoundError: If no webhook is configured.
649            MailAuthError: If the token is invalid.
650
651        Example:
652            >>> wh = mb.webhook()
653            >>> print(wh.url, wh.events)
654        """
655        data = self._request("GET", "/v1/me/webhook")
656        return WebhookInfo(**data)

Get current webhook config.

Returns: WebhookInfo with id, url, events, and created_at.

Raises: MailNotFoundError: If no webhook is configured. MailAuthError: If the token is invalid.

Example:

wh = mb.webhook() print(wh.url, wh.events)

def delete_webhook(self) -> None:
658    def delete_webhook(self) -> None:
659        """Remove the webhook.
660
661        Raises:
662            MailNotFoundError: If no webhook is configured.
663            MailAuthError: If the token is invalid.
664
665        Example:
666            >>> mb.delete_webhook()
667        """
668        self._request("DELETE", "/v1/me/webhook")

Remove the webhook.

Raises: MailNotFoundError: If no webhook is configured. MailAuthError: If the token is invalid.

Example:

mb.delete_webhook()

def delete(self) -> None:
672    def delete(self) -> None:
673        """Delete this mailbox from the server and close the HTTP client.
674
675        This permanently destroys the mailbox and all its messages. The
676        Mailbox instance cannot be used after this call.
677
678        Raises:
679            MailAuthError: If the token is invalid.
680            MailNetworkError: On connection failure.
681
682        Example:
683            >>> mb.delete()  # mailbox is gone
684        """
685        self._request("DELETE", "/v1/me")
686        self._destroyed = True
687        if self._owns_client:
688            self._client.close()

Delete this mailbox from the server and close the HTTP client.

This permanently destroys the mailbox and all its messages. The Mailbox instance cannot be used after this call.

Raises: MailAuthError: If the token is invalid. MailNetworkError: On connection failure.

Example:

mb.delete() # mailbox is gone

def close(self) -> None:
690    def close(self) -> None:
691        """Close the HTTP client.
692
693        The mailbox remains active on the server. Use ``delete()`` to
694        destroy it entirely. Called automatically when used as a context
695        manager.
696
697        Example:
698            >>> mb.close()
699        """
700        if self._owns_client:
701            self._client.close()

Close the HTTP client.

The mailbox remains active on the server. Use delete() to destroy it entirely. Called automatically when used as a context manager.

Example:

mb.close()

class PaginatedList(pydantic.main.BaseModel, typing.Generic[~T]):
13class PaginatedList(BaseModel, Generic[T]):
14    """Paginated list response. Iterable — ``for msg in inbox:`` works directly.
15
16    Supports automatic pagination::
17
18        for msg in mb.inbox().auto_paging_iter():
19            print(msg.subject)
20    """
21
22    items: list[T]
23    cursor: str | None = None
24    has_more: bool = False
25
26    _fetch_fn: Callable[..., PaginatedList[T]] | None = PrivateAttr(default=None)
27    _fetch_kwargs: dict[str, Any] = PrivateAttr(default_factory=dict)
28
29    def __iter__(self) -> Iterator[T]:
30        return iter(self.items)
31
32    def __getitem__(self, index: int) -> T:
33        return self.items[index]
34
35    def __len__(self) -> int:
36        return len(self.items)
37
38    def __bool__(self) -> bool:
39        return bool(self.items)
40
41    def auto_paging_iter(self, *, max_pages: int = 1000) -> Iterator[T]:
42        """Iterate through all pages automatically.
43
44        Args:
45            max_pages: Safety limit on total pages fetched (default 1000).
46        """
47        page = self
48        for _ in range(max_pages):
49            yield from page.items
50            if not page.has_more or page.cursor is None or page._fetch_fn is None:
51                break
52            page = page._fetch_fn(**{**page._fetch_kwargs, "cursor": page.cursor})

Paginated list response. Iterable — for msg in inbox: works directly.

Supports automatic pagination::

for msg in mb.inbox().auto_paging_iter():
    print(msg.subject)
items: list[~T] = PydanticUndefined
cursor: str | None = None
has_more: bool = False
def auto_paging_iter(self, *, max_pages: int = 1000) -> Iterator[~T]:
41    def auto_paging_iter(self, *, max_pages: int = 1000) -> Iterator[T]:
42        """Iterate through all pages automatically.
43
44        Args:
45            max_pages: Safety limit on total pages fetched (default 1000).
46        """
47        page = self
48        for _ in range(max_pages):
49            yield from page.items
50            if not page.has_more or page.cursor is None or page._fetch_fn is None:
51                break
52            page = page._fetch_fn(**{**page._fetch_kwargs, "cursor": page.cursor})

Iterate through all pages automatically.

Args: max_pages: Safety limit on total pages fetched (default 1000).

def verify_webhook( *, secret: str, signature: str, body: str | bytes, timestamp: str | None = None, tolerance: int = 300) -> dict:
14def verify_webhook(
15    *,
16    secret: str,
17    signature: str,
18    body: str | bytes,
19    timestamp: str | None = None,
20    tolerance: int = 300,
21) -> dict:
22    """Verify a webhook payload and return the parsed data.
23
24    Works for both Mail webhooks (with timestamp) and Approval webhooks
25    (without). Uses HMAC-SHA256 with timing-safe comparison.
26
27    Args:
28        secret: The webhook signing secret (from ``WebhookCreated.secret``
29            or the Primitif console).
30        signature: Value of the signature header (``X-Webhook-Signature``).
31            May optionally include a ``sha256=`` prefix.
32        body: Raw request body (str or bytes).
33        timestamp: Value of the timestamp header (unix seconds). Required
34            for Mail webhooks, optional for Approval webhooks.
35        tolerance: Max age in seconds. Defaults to 300 (5 min). Only
36            checked when ``timestamp`` is provided. Set to 0 to disable
37            replay protection.
38
39    Returns:
40        Parsed JSON payload as a dict.
41
42    Raises:
43        InvalidSignature: If the signature does not match, the timestamp
44            is missing/malformed, or the payload is too old.
45
46    Example:
47        >>> from primitif import verify_webhook
48        >>> event = verify_webhook(
49        ...     secret="whsec_abc123...",
50        ...     signature=request.headers["X-Webhook-Signature"],
51        ...     body=request.data,
52        ...     timestamp=request.headers.get("X-Webhook-Timestamp"),
53        ... )
54        >>> print(event["type"], event["data"])
55    """
56    if not signature:
57        raise InvalidSignature("Missing signature")
58    if isinstance(body, bytes):
59        body = body.decode("utf-8")
60
61    # Replay protection (only when timestamp provided)
62    if timestamp is not None and tolerance > 0:
63        try:
64            ts = int(timestamp)
65        except (ValueError, TypeError):
66            raise InvalidSignature("Invalid timestamp") from None
67        if abs(_time.time() - ts) > tolerance:
68            raise InvalidSignature("Timestamp too old")
69
70    # Build the signed message
71    if timestamp is not None:
72        message = f"{timestamp}.{body}"
73    else:
74        message = body
75
76    expected = hmac.new(
77        secret.encode(), message.encode(), hashlib.sha256
78    ).hexdigest()
79
80    # Strip sha256= prefix if present
81    sig = signature.removeprefix("sha256=")
82
83    if not hmac.compare_digest(expected, sig):
84        raise InvalidSignature("Signature mismatch")
85
86    return json.loads(body)

Verify a webhook payload and return the parsed data.

Works for both Mail webhooks (with timestamp) and Approval webhooks (without). Uses HMAC-SHA256 with timing-safe comparison.

Args: secret: The webhook signing secret (from WebhookCreated.secret or the Primitif console). signature: Value of the signature header (X-Webhook-Signature). May optionally include a sha256= prefix. body: Raw request body (str or bytes). timestamp: Value of the timestamp header (unix seconds). Required for Mail webhooks, optional for Approval webhooks. tolerance: Max age in seconds. Defaults to 300 (5 min). Only checked when timestamp is provided. Set to 0 to disable replay protection.

Returns: Parsed JSON payload as a dict.

Raises: InvalidSignature: If the signature does not match, the timestamp is missing/malformed, or the payload is too old.

Example:

from primitif import verify_webhook event = verify_webhook( ... secret="whsec_abc123...", ... signature=request.headers["X-Webhook-Signature"], ... body=request.data, ... timestamp=request.headers.get("X-Webhook-Timestamp"), ... ) print(event["type"], event["data"])

class PrimitifError(builtins.Exception):
12class PrimitifError(Exception):
13    """Base exception for all Primitif SDK errors."""
14
15    def __init__(
16        self,
17        message: str = "",
18        *,
19        status_code: int = 0,
20        code: str = "",
21        response: "httpx.Response | None" = None,
22        request_id: str | None = None,
23    ) -> None:
24        self.message = message
25        self.status_code = status_code
26        self.code = code
27        self.response = response
28        self.request_id = request_id
29        if status_code:
30            super().__init__(f"{status_code} {code}: {message}")
31        else:
32            super().__init__(message)

Base exception for all Primitif SDK errors.

PrimitifError( message: str = '', *, status_code: int = 0, code: str = '', response: httpx.Response | None = None, request_id: str | None = None)
15    def __init__(
16        self,
17        message: str = "",
18        *,
19        status_code: int = 0,
20        code: str = "",
21        response: "httpx.Response | None" = None,
22        request_id: str | None = None,
23    ) -> None:
24        self.message = message
25        self.status_code = status_code
26        self.code = code
27        self.response = response
28        self.request_id = request_id
29        if status_code:
30            super().__init__(f"{status_code} {code}: {message}")
31        else:
32            super().__init__(message)
message
status_code
code
response
request_id
class AuthError(primitif.PrimitifError):
35class AuthError(PrimitifError):
36    """401 — invalid or missing credentials."""

401 — invalid or missing credentials.

class NotFoundError(primitif.PrimitifError):
39class NotFoundError(PrimitifError):
40    """404 — resource not found."""

404 — resource not found.

class ConflictError(primitif.PrimitifError):
43class ConflictError(PrimitifError):
44    """409 — resource conflict."""

409 — resource conflict.

class ValidationError(primitif.PrimitifError):
47class ValidationError(PrimitifError):
48    """422 — invalid request payload."""

422 — invalid request payload.

class RateLimitError(primitif.PrimitifError):
51class RateLimitError(PrimitifError):
52    """429 — rate limit exceeded."""
53
54    @property
55    def retry_after(self) -> float | None:
56        """Seconds to wait before retrying, from the Retry-After header."""
57        if self.response is not None:
58            val = self.response.headers.get("retry-after")
59            if val:
60                try:
61                    return float(val)
62                except (ValueError, TypeError):
63                    return None
64        return None

429 — rate limit exceeded.

retry_after: float | None
54    @property
55    def retry_after(self) -> float | None:
56        """Seconds to wait before retrying, from the Retry-After header."""
57        if self.response is not None:
58            val = self.response.headers.get("retry-after")
59            if val:
60                try:
61                    return float(val)
62                except (ValueError, TypeError):
63                    return None
64        return None

Seconds to wait before retrying, from the Retry-After header.

class ServerError(primitif.PrimitifError):
67class ServerError(PrimitifError):
68    """5xx — server error."""

5xx — server error.

class NetworkError(primitif.PrimitifError):
71class NetworkError(PrimitifError):
72    """Network-level failure — timeout, DNS, TCP, or TLS error."""

Network-level failure — timeout, DNS, TCP, or TLS error.

class InvalidSignature(primitif.PrimitifError):
75class InvalidSignature(PrimitifError):
76    """Webhook signature verification failed."""

Webhook signature verification failed.

def require_approval( fn: Optional[Callable] = None, *, action: str | None = None, requested_by: str | None = None, callback_url: str | None = None) -> Callable:
23def require_approval(
24    fn: Callable | None = None,
25    *,
26    action: str | None = None,
27    requested_by: str | None = None,
28    callback_url: str | None = None,
29) -> Callable:
30    """Standalone decorator that gates a function behind human approval.
31
32    Reads ``PRIMITIF_API_KEY`` from the environment. No client setup needed.
33    Creates a new Primitif client on each call.
34
35    Can be used with or without parentheses: ``@require_approval`` or
36    ``@require_approval(action="Deploy")``.
37
38    Args:
39        fn: The function to wrap. Passed automatically when used as
40            ``@require_approval`` without parentheses. Do not pass explicitly.
41        action: Human-readable action name shown to the approver.
42            Defaults to the function name with underscores replaced by
43            spaces (``send_invoice`` -> ``"Send invoice"``).
44        requested_by: Optional agent or service identifier attached to the
45            approval request. Defaults to None.
46        callback_url: Optional webhook URL for direct callback when the
47            request is decided. Defaults to None.
48
49    Returns:
50        The decorated function. Each call returns an ApprovalRequest
51        (status ``"pending"``) instead of executing the original function.
52        The original is accessible via ``wrapped.original``.
53
54    Raises:
55        PrimitifError: If ``PRIMITIF_API_KEY`` is not set.
56
57    Example:
58        >>> from primitif.approval import require_approval
59        >>> @require_approval
60        ... def deploy(service, branch, env):
61        ...     run_pipeline(service, branch, env)
62        >>> req = deploy("api-gateway", "main", "production")
63        >>> print(req.approval_url)  # share with the human
64
65        >>> @require_approval(requested_by="deploy-agent")
66        ... def deploy(service, branch, env):
67        ...     run_pipeline(service, branch, env)
68    """
69
70    def decorator(fn: Callable) -> Callable:
71        resolved_action = action or fn.__name__.replace("_", " ").capitalize()
72
73        @functools.wraps(fn)
74        def wrapper(*args: Any, **kwargs: Any) -> ApprovalRequest:
75            from primitif.client import Primitif
76
77            p = Primitif()
78            sig = inspect.signature(fn)
79            bound = sig.bind(*args, **kwargs)
80            bound.apply_defaults()
81            context = dict(bound.arguments)
82
83            return p.approval.create_request(
84                resolved_action,
85                context=context,
86                requested_by=requested_by,
87                callback_url=callback_url,
88            )
89
90        wrapper.original = fn  # type: ignore[attr-defined]
91        return wrapper
92
93    # Support both @require_approval and @require_approval(...)
94    if fn is not None:
95        return decorator(fn)
96    return decorator

Standalone decorator that gates a function behind human approval.

Reads PRIMITIF_API_KEY from the environment. No client setup needed. Creates a new Primitif client on each call.

Can be used with or without parentheses: @require_approval or @require_approval(action="Deploy").

Args: fn: The function to wrap. Passed automatically when used as @require_approval without parentheses. Do not pass explicitly. action: Human-readable action name shown to the approver. Defaults to the function name with underscores replaced by spaces (send_invoice -> "Send invoice"). requested_by: Optional agent or service identifier attached to the approval request. Defaults to None. callback_url: Optional webhook URL for direct callback when the request is decided. Defaults to None.

Returns: The decorated function. Each call returns an ApprovalRequest (status "pending") instead of executing the original function. The original is accessible via wrapped.original.

Raises: PrimitifError: If PRIMITIF_API_KEY is not set.

Example:

from primitif.approval import require_approval @require_approval ... def deploy(service, branch, env): ... run_pipeline(service, branch, env) req = deploy("api-gateway", "main", "production") print(req.approval_url) # share with the human

>>> @require_approval(requested_by="deploy-agent")
... def deploy(service, branch, env):
...     run_pipeline(service, branch, env)