primitif API documentation

# primitif

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

View Source

     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: View Source

     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) View Source

     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: View Source

    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: View Source

     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) View Source

     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 View Source

    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 View Source

    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: View Source

    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]: View Source

    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: View Source

    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]: View Source

    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: View Source

    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: View Source

    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: View Source

    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]: View Source

    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: View Source

    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]: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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]): View Source

    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]: View Source

    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: View Source

    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): View Source

    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) View Source

    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): View Source

    35class AuthError(PrimitifError):
    36    """401 — invalid or missing credentials."""

401 — invalid or missing credentials.

class NotFoundError(primitif.PrimitifError): View Source

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

404 — resource not found.

class ConflictError(primitif.PrimitifError): View Source

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

409 — resource conflict.

class ValidationError(primitif.PrimitifError): View Source

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

422 — invalid request payload.

class RateLimitError(primitif.PrimitifError): View Source

    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 View Source

    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): View Source

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

5xx — server error.

class NetworkError(primitif.PrimitifError): View Source

    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): View Source

    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: View Source

    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)