From 26472987ad035ee8d43ce3534504dc104925710e Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:09:40 -0300 Subject: [PATCH 01/20] feat(inboxes): add the Inboxes resource Add resend.Inboxes with the Agent, Labels, Threads, Threads.Emails and Drafts sub-resources. The 26 methods cover every route under /inboxes, each with a sync and an async variant. Path IDs go in the params dict for mutations and positionally for reads, as in the other resources. They are kept out of the request body. List and search queries join list values with commas and send booleans as true or false. --- resend/__init__.py | 42 ++ resend/inboxes/__init__.py | 47 ++ resend/inboxes/_inbox.py | 105 ++++ resend/inboxes/_inboxes.py | 426 +++++++++++++++ resend/inboxes/_query.py | 41 ++ resend/inboxes/agent/__init__.py | 0 resend/inboxes/agent/_inbox_agent.py | 152 ++++++ resend/inboxes/agent/_inbox_agent_settings.py | 43 ++ resend/inboxes/drafts/__init__.py | 0 resend/inboxes/drafts/_inbox_draft.py | 124 +++++ resend/inboxes/drafts/_inbox_drafts.py | 499 +++++++++++++++++ resend/inboxes/labels/__init__.py | 0 resend/inboxes/labels/_inbox_label.py | 24 + resend/inboxes/labels/_inbox_labels.py | 309 +++++++++++ resend/inboxes/threads/__init__.py | 0 resend/inboxes/threads/_inbox_thread.py | 162 ++++++ resend/inboxes/threads/_inbox_threads.py | 505 ++++++++++++++++++ resend/inboxes/threads/emails/__init__.py | 0 .../threads/emails/_inbox_thread_email.py | 96 ++++ .../threads/emails/_inbox_thread_emails.py | 450 ++++++++++++++++ 20 files changed, 3025 insertions(+) create mode 100644 resend/inboxes/__init__.py create mode 100644 resend/inboxes/_inbox.py create mode 100644 resend/inboxes/_inboxes.py create mode 100644 resend/inboxes/_query.py create mode 100644 resend/inboxes/agent/__init__.py create mode 100644 resend/inboxes/agent/_inbox_agent.py create mode 100644 resend/inboxes/agent/_inbox_agent_settings.py create mode 100644 resend/inboxes/drafts/__init__.py create mode 100644 resend/inboxes/drafts/_inbox_draft.py create mode 100644 resend/inboxes/drafts/_inbox_drafts.py create mode 100644 resend/inboxes/labels/__init__.py create mode 100644 resend/inboxes/labels/_inbox_label.py create mode 100644 resend/inboxes/labels/_inbox_labels.py create mode 100644 resend/inboxes/threads/__init__.py create mode 100644 resend/inboxes/threads/_inbox_thread.py create mode 100644 resend/inboxes/threads/_inbox_threads.py create mode 100644 resend/inboxes/threads/emails/__init__.py create mode 100644 resend/inboxes/threads/emails/_inbox_thread_email.py create mode 100644 resend/inboxes/threads/emails/_inbox_thread_emails.py diff --git a/resend/__init__.py b/resend/__init__.py index 0510536..abdd79d 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -53,6 +53,25 @@ from .http_client_async import \ AsyncHTTPClient # Okay to import AsyncHTTPClient since it is just an interface. from .http_client_requests import RequestsClient +from .inboxes._inbox import (Inbox, InboxListItem, InboxMessageFolder, + MoveThreadFolder) +from .inboxes._inboxes import Inboxes +from .inboxes.agent._inbox_agent import InboxAgent +from .inboxes.agent._inbox_agent_settings import (InboxAgentAction, + InboxAgentSettings) +from .inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftListItem, + InboxDraftType) +from .inboxes.drafts._inbox_drafts import InboxDrafts +from .inboxes.labels._inbox_label import InboxLabel +from .inboxes.labels._inbox_labels import InboxLabels +from .inboxes.threads._inbox_thread import (InboxThread, InboxThreadLabel, + InboxThreadSearchHighlights, + InboxThreadSearchResult, + InboxThreadSummary) +from .inboxes.threads._inbox_threads import InboxThreads +from .inboxes.threads.emails._inbox_thread_email import ( + InboxThreadEmail, InboxThreadEmailAttachment) +from .inboxes.threads.emails._inbox_thread_emails import InboxThreadEmails from .logs._log import Log from .logs._logs import Logs from .oauth_grants._oauth_grant import OAuthGrant, OAuthGrantClient @@ -141,6 +160,12 @@ "Suppressions", "SuppressionsBatch", "Usage", + "Inboxes", + "InboxAgent", + "InboxLabels", + "InboxThreads", + "InboxThreadEmails", + "InboxDrafts", # Types "Audience", "Automation", @@ -229,6 +254,23 @@ "TopicCreatedEvent", "TopicUpdatedEvent", "TopicDeletedEvent", + "Inbox", + "InboxListItem", + "InboxMessageFolder", + "MoveThreadFolder", + "InboxAgentAction", + "InboxAgentSettings", + "InboxLabel", + "InboxThread", + "InboxThreadLabel", + "InboxThreadSearchHighlights", + "InboxThreadSearchResult", + "InboxThreadSummary", + "InboxThreadEmail", + "InboxThreadEmailAttachment", + "InboxDraft", + "InboxDraftListItem", + "InboxDraftType", "Topic", "OAuthGrant", "OAuthGrantClient", diff --git a/resend/inboxes/__init__.py b/resend/inboxes/__init__.py new file mode 100644 index 0000000..bca24d4 --- /dev/null +++ b/resend/inboxes/__init__.py @@ -0,0 +1,47 @@ +from resend.inboxes._inbox import (Inbox, InboxListItem, InboxMessageFolder, + MoveThreadFolder) +from resend.inboxes._inboxes import Inboxes +from resend.inboxes.agent._inbox_agent import InboxAgent +from resend.inboxes.agent._inbox_agent_settings import (InboxAgentAction, + InboxAgentSettings) +from resend.inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftListItem, + InboxDraftType) +from resend.inboxes.drafts._inbox_drafts import InboxDrafts +from resend.inboxes.labels._inbox_label import InboxLabel +from resend.inboxes.labels._inbox_labels import InboxLabels +from resend.inboxes.threads._inbox_thread import (InboxThread, + InboxThreadLabel, + InboxThreadSearchHighlights, + InboxThreadSearchResult, + InboxThreadSummary) +from resend.inboxes.threads._inbox_threads import InboxThreads +from resend.inboxes.threads.emails._inbox_thread_email import ( + InboxThreadEmail, InboxThreadEmailAttachment) +from resend.inboxes.threads.emails._inbox_thread_emails import \ + InboxThreadEmails + +__all__ = [ + "Inboxes", + "InboxAgent", + "InboxLabels", + "InboxThreads", + "InboxThreadEmails", + "InboxDrafts", + "Inbox", + "InboxListItem", + "InboxMessageFolder", + "MoveThreadFolder", + "InboxAgentAction", + "InboxAgentSettings", + "InboxLabel", + "InboxThread", + "InboxThreadLabel", + "InboxThreadSearchHighlights", + "InboxThreadSearchResult", + "InboxThreadSummary", + "InboxThreadEmail", + "InboxThreadEmailAttachment", + "InboxDraft", + "InboxDraftListItem", + "InboxDraftType", +] diff --git a/resend/inboxes/_inbox.py b/resend/inboxes/_inbox.py new file mode 100644 index 0000000..f82c90b --- /dev/null +++ b/resend/inboxes/_inbox.py @@ -0,0 +1,105 @@ +from typing import Optional + +from typing_extensions import Literal, TypedDict + +from resend._base_response import BaseResponse + +InboxMessageFolder = Literal["inbox", "archive", "spam", "sent", "trash"] +""" +A folder of an inbox. Every thread is in exactly one folder. ``inbox`` holds +threads with at least one received email. ``archive`` and ``spam`` hold threads +moved there. ``trash`` holds threads scheduled for deletion in 30 days. ``sent`` +holds threads whose emails are all outbound, and is assigned automatically. +""" + +MoveThreadFolder = Literal["inbox", "archive", "spam", "trash"] +""" +A folder a thread can be moved to. ``sent`` is assigned automatically and +cannot be set. +""" + + +class Inbox(BaseResponse): + """ + Inbox is the full inbox object, as returned when you retrieve an inbox. + """ + + object: str + """ + The object type. Always ``inbox``. + """ + id: str + """ + The ID of the inbox. + """ + name: str + """ + Internal name for the inbox. Recipients do not see it. + Falls back to the inbox email address. + """ + email_address: str + """ + The address of the inbox. + """ + domain_id: str + """ + The ID of the domain that owns the address. + """ + receiving_address: Optional[str] + """ + The address to forward mail to when forwarding is enabled. None otherwise. + """ + from_name: Optional[str] + """ + The name recipients see when mail is sent from this inbox. None when not set. + """ + unread: int + """ + The number of unread threads in the Inbox folder. + """ + drafts: int + """ + The number of unsent drafts. + """ + last_received: Optional[str] + """ + ISO 8601 timestamp of the newest received email in the Inbox folder. + None when the folder has no received email. + """ + created_at: str + """ + ISO 8601 timestamp when the inbox was created. + """ + + +class InboxListItem(TypedDict): + """ + InboxListItem is an inbox as it appears in the list of inboxes. + It carries fewer fields than a single inbox. + """ + + id: str + """ + The ID of the inbox. + """ + name: Optional[str] + """ + Internal name for the inbox. Recipients do not see it. + """ + email_address: str + """ + The address of the inbox. + """ + from_name: Optional[str] + """ + The name recipients see when mail is sent from this inbox. None when not set. + """ + unread: int + """ + The number of unread threads in the Inbox folder. + """ + last_received: Optional[str] + """ + ISO 8601 timestamp of the newest received email in the Inbox folder. + None when the folder has no received email. + """ diff --git a/resend/inboxes/_inboxes.py b/resend/inboxes/_inboxes.py new file mode 100644 index 0000000..a97e35e --- /dev/null +++ b/resend/inboxes/_inboxes.py @@ -0,0 +1,426 @@ +from typing import Any, Dict, List, Optional, cast +from urllib.parse import quote + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.inboxes._inbox import Inbox, InboxListItem +from resend.inboxes._query import without_keys +from resend.pagination_helper import PaginationHelper + +from .agent._inbox_agent import InboxAgent +from .drafts._inbox_drafts import InboxDrafts +from .labels._inbox_labels import InboxLabels +from .threads._inbox_threads import InboxThreads + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class Inboxes: + """ + Inboxes manages inboxes, which send and receive email at an address on one + of your domains, and their agent settings, labels, threads and drafts. + """ + + class Agent(InboxAgent): + pass + + class Labels(InboxLabels): + pass + + class Threads(InboxThreads): + pass + + class Drafts(InboxDrafts): + pass + + class CreateParams(TypedDict): + """ + CreateParams are the parameters for creating an inbox. + + Attributes: + email_address (str): The inbox email address on one of your domains. + name (NotRequired[str]): Internal name for the inbox. + from_name (NotRequired[str]): The name recipients see when mail is sent from this inbox. + forwarding (NotRequired[bool]): Provision a receiving address instead of an MX record. + """ + + email_address: str + """ + The inbox email address on one of your domains. The API trims and + lowercases it. The part before the ``@`` is at most 64 characters and + contains only letters, numbers, and ``. _ % + -``. It cannot start or end + with a dot or contain two dots in a row. + """ + name: NotRequired[str] + """ + Internal name for the inbox. Recipients do not see it. Max 64 characters. + Defaults to ``email_address``. + """ + from_name: NotRequired[str] + """ + The name recipients see when mail is sent from this inbox. A plain name, + not a ``Name `` address. Max 78 characters. It cannot contain a + colon, ``<``, ``>``, line breaks, or other control characters. An email + address inside the name must be on a domain the team has verified for + sending. An empty string leaves it unset. + """ + forwarding: NotRequired[bool] + """ + When True, Resend provisions a receiving address so you can receive mail + without adding an MX record. The address is returned in ``receiving_address``. + Defaults to False. + """ + + class CreateResponse(BaseResponse): + """ + CreateResponse is the response of creating an inbox. + + Attributes: + object (str): The object type, always "inbox" + id (str): The ID of the inbox + """ + + object: str + """ + The object type, always "inbox". + """ + id: str + """ + The ID of the inbox. + """ + + class ListParams(TypedDict): + """ + ListParams are the pagination parameters for listing inboxes. + + Attributes: + limit (NotRequired[int]): Number of inboxes to retrieve. Maximum is 100, minimum is 1. + after (NotRequired[str]): The ID after which to retrieve more inboxes. + before (NotRequired[str]): The ID before which to retrieve more inboxes. + """ + + limit: NotRequired[int] + """ + Number of inboxes to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The ID after which we'll retrieve more inboxes (for pagination). + This ID will not be included in the returned list. + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The ID before which we'll retrieve more inboxes (for pagination). + This ID will not be included in the returned list. + Cannot be used with the after parameter. + """ + + class ListResponse(BaseResponse): + """ + ListResponse is a page of the inboxes of the team. + + Attributes: + object (str): The object type, always "list" + has_more (bool): Whether there are more results available + data (List[InboxListItem]): The inboxes, newest first + """ + + object: str + """ + The object type, always "list". + """ + has_more: bool + """ + Whether there are more results available for pagination. + """ + data: List[InboxListItem] + """ + The inboxes on this page, newest first. + """ + + class UpdateParams(TypedDict): + """ + UpdateParams are the parameters for updating an inbox. + Provide at least one of ``name`` or ``from_name``. + + Attributes: + id (str): The Inbox ID. An email address is not accepted. + name (NotRequired[str]): New internal name for the inbox. + from_name (NotRequired[str]): The name recipients see when mail is sent from this inbox. + """ + + id: str + """ + The Inbox ID. An email address is not accepted. + """ + name: NotRequired[str] + """ + New internal name for the inbox. Recipients do not see it. + Between 1 and 64 characters after trimming. + """ + from_name: NotRequired[str] + """ + The name recipients see when mail is sent from this inbox. A plain name, + not a ``Name `` address. Max 78 characters. It cannot contain a + colon, ``<``, ``>``, line breaks, or other control characters. An email + address inside the name must be on a domain the team has verified for + sending. An empty string removes it. + """ + + class UpdateResponse(BaseResponse): + """ + UpdateResponse is the response of updating an inbox. + + Attributes: + object (str): The object type, always "inbox" + id (str): The ID of the inbox + """ + + object: str + """ + The object type, always "inbox". + """ + id: str + """ + The ID of the inbox. + """ + + class RemoveResponse(BaseResponse): + """ + RemoveResponse is the response of removing an inbox. + + Attributes: + object (str): The object type, always "inbox" + id (str): The ID of the inbox + deleted (bool): Whether the inbox was deleted + """ + + object: str + """ + The object type, always "inbox". + """ + id: str + """ + The ID of the inbox. + """ + deleted: bool + """ + Indicates whether the inbox was successfully deleted. + """ + + @classmethod + def create(cls, params: CreateParams) -> CreateResponse: + """ + Create an inbox that sends and receives email at an address on one of your + domains. The domain must belong to the team. Without ``forwarding``, the + domain must have receiving enabled, with DKIM and SPF verified and the + inbound MX record verified or pending. With ``forwarding`` set to True, the + domain only needs DKIM and SPF verified for sending, and Resend generates a + ``receiving_address`` on the team inbound domain. A domain that does not + meet these rules returns a 422 validation_error. An address or receiving + address that is already in use returns a 409. A team that has reached the + inbox limit of its plan returns a 403 validation_error. + see more: https://resend.com/docs/api-reference/inboxes/create-inbox + + Args: + params (CreateParams): The inbox parameters + + Returns: + CreateResponse: The object type and the ID of the inbox + """ + path = "/inboxes" + resp = request.Request[Inboxes.CreateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + def list(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve the inboxes of the team, newest first. The list items carry fewer + fields than a single inbox. Retrieve an inbox to get all its fields. + see more: https://resend.com/docs/api-reference/inboxes/list-inboxes + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A page of the inboxes of the team + """ + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path("/inboxes", query_params) + resp = request.Request[Inboxes.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def get(cls, inbox_id: str) -> Inbox: + """ + Retrieve a single inbox by ID or email address. + see more: https://resend.com/docs/api-reference/inboxes/get-inbox + + Args: + inbox_id (str): The Inbox ID or the inbox email address, such as + ``support@acme.com``. A value that contains ``@`` must be a valid + email address, and the match is case-insensitive. Any other value + must be a UUID. + + Returns: + Inbox: The inbox object + """ + path = f"/inboxes/{quote(inbox_id, safe='')}" + resp = request.Request[Inbox]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def update(cls, params: UpdateParams) -> UpdateResponse: + """ + Update the internal name of an inbox or the name recipients see when mail + is sent from it. At least one of ``name`` or ``from_name`` is required. + see more: https://resend.com/docs/api-reference/inboxes/update-inbox + + Args: + params (UpdateParams): The inbox ID and the fields to change + + Returns: + UpdateResponse: The object type and the ID of the inbox + """ + path = f"/inboxes/{params['id']}" + resp = request.Request[Inboxes.UpdateResponse]( + path=path, params=without_keys(params, "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + def remove(cls, inbox_id: str) -> RemoveResponse: + """ + Remove an inbox. An inbox that does not exist or was already removed + returns a 404 not_found. + see more: https://resend.com/docs/api-reference/inboxes/delete-inbox + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + + Returns: + RemoveResponse: The inbox ID and its deleted status + """ + path = f"/inboxes/{inbox_id}" + resp = request.Request[Inboxes.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """ + Create an inbox that sends and receives email at an address on one of your + domains (async). The domain must belong to the team. Without ``forwarding``, + the domain must have receiving enabled, with DKIM and SPF verified and the + inbound MX record verified or pending. With ``forwarding`` set to True, the + domain only needs DKIM and SPF verified for sending, and Resend generates a + ``receiving_address`` on the team inbound domain. A domain that does not + meet these rules returns a 422 validation_error. An address or receiving + address that is already in use returns a 409. A team that has reached the + inbox limit of its plan returns a 403 validation_error. + see more: https://resend.com/docs/api-reference/inboxes/create-inbox + + Args: + params (CreateParams): The inbox parameters + + Returns: + CreateResponse: The object type and the ID of the inbox + """ + path = "/inboxes" + resp = await AsyncRequest[Inboxes.CreateResponse]( + path=path, params=cast(Dict[Any, Any], params), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve the inboxes of the team, newest first (async). The list items + carry fewer fields than a single inbox. Retrieve an inbox to get all its fields. + see more: https://resend.com/docs/api-reference/inboxes/list-inboxes + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A page of the inboxes of the team + """ + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path("/inboxes", query_params) + resp = await AsyncRequest[Inboxes.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, inbox_id: str) -> Inbox: + """ + Retrieve a single inbox by ID or email address (async). + see more: https://resend.com/docs/api-reference/inboxes/get-inbox + + Args: + inbox_id (str): The Inbox ID or the inbox email address, such as + ``support@acme.com``. A value that contains ``@`` must be a valid + email address, and the match is case-insensitive. Any other value + must be a UUID. + + Returns: + Inbox: The inbox object + """ + path = f"/inboxes/{quote(inbox_id, safe='')}" + resp = await AsyncRequest[Inbox]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Update the internal name of an inbox or the name recipients see when mail + is sent from it (async). At least one of ``name`` or ``from_name`` is required. + see more: https://resend.com/docs/api-reference/inboxes/update-inbox + + Args: + params (UpdateParams): The inbox ID and the fields to change + + Returns: + UpdateResponse: The object type and the ID of the inbox + """ + path = f"/inboxes/{params['id']}" + resp = await AsyncRequest[Inboxes.UpdateResponse]( + path=path, params=without_keys(params, "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, inbox_id: str) -> RemoveResponse: + """ + Remove an inbox (async). An inbox that does not exist or was already + removed returns a 404 not_found. + see more: https://resend.com/docs/api-reference/inboxes/delete-inbox + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + + Returns: + RemoveResponse: The inbox ID and its deleted status + """ + path = f"/inboxes/{inbox_id}" + resp = await AsyncRequest[Inboxes.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/inboxes/_query.py b/resend/inboxes/_query.py new file mode 100644 index 0000000..ac1c12a --- /dev/null +++ b/resend/inboxes/_query.py @@ -0,0 +1,41 @@ +from typing import Any, Dict, Mapping, Optional + +from resend.pagination_helper import PaginationHelper + + +def build_inbox_query_path(path: str, params: Optional[Mapping[str, Any]] = None) -> str: + """ + Build a path with the query parameters of an inbox list or search call. + Lists become comma-separated values, and booleans become the literal + ``true`` or ``false`` that the API accepts. + + Args: + path: The base API path (e.g., "/inboxes/{inbox_id}/threads") + params: Optional query parameters + + Returns: + The path with the query parameters appended, if any are set + """ + if not params: + return path + + query: Dict[str, Any] = {} + for key, value in params.items(): + if value is None: + continue + if isinstance(value, bool): + query[key] = "true" if value else "false" + elif isinstance(value, (list, tuple)): + query[key] = ",".join(str(item) for item in value) + else: + query[key] = value + + return PaginationHelper.build_paginated_path(path, query) + + +def without_keys(params: Mapping[str, Any], *keys: str) -> Dict[str, Any]: + """ + Return a copy of the params without the given keys. The SDK puts the + path IDs in the params dict, but the API takes them in the path only. + """ + return {k: v for k, v in params.items() if k not in keys} diff --git a/resend/inboxes/agent/__init__.py b/resend/inboxes/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resend/inboxes/agent/_inbox_agent.py b/resend/inboxes/agent/_inbox_agent.py new file mode 100644 index 0000000..515fa5f --- /dev/null +++ b/resend/inboxes/agent/_inbox_agent.py @@ -0,0 +1,152 @@ +from typing import List, Optional + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.inboxes._query import without_keys + +from ._inbox_agent_settings import InboxAgentAction, InboxAgentSettings + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class InboxAgent: + """ + InboxAgent manages the agent settings of an inbox. + """ + + class UpdateParams(TypedDict): + """ + UpdateParams are the parameters for updating the agent settings of an inbox. + At least one of ``instructions``, ``tone``, or ``enabled_actions`` must be + provided. Omitted fields keep their current value. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + instructions (NotRequired[Optional[str]]): Instructions for the agent. + tone (NotRequired[Optional[str]]): The tone the agent writes in. + enabled_actions (NotRequired[List[InboxAgentAction]]): The actions the agent can take. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + instructions: NotRequired[Optional[str]] + """ + Instructions the agent follows when it handles the threads of the inbox. + Max 4000 characters. Leading and trailing whitespace is trimmed, and the + trimmed value must not be empty. Send None to clear the instructions. + """ + tone: NotRequired[Optional[str]] + """ + The tone the agent writes in, such as ``friendly and concise``. + Max 64 characters. Leading and trailing whitespace is trimmed, and the + trimmed value must not be empty. Send None to clear the tone. + """ + enabled_actions: NotRequired[List[InboxAgentAction]] + """ + The actions the agent can take. Replaces the whole set. + Send an empty list to disable all actions. + """ + + class UpdateResponse(BaseResponse): + """ + UpdateResponse is the response of updating the agent settings of an inbox. + + Attributes: + object (str): The object type. Always "inbox_agent". + id (str): The ID of the inbox agent. + """ + + object: str + """ + The object type. Always ``inbox_agent``. + """ + id: str + """ + The ID of the inbox agent. + """ + + @classmethod + def get(cls, inbox_id: str) -> InboxAgentSettings: + """ + Retrieve the agent settings of an inbox. An inbox without a configured + agent returns empty settings: ``instructions`` and ``tone`` are None and + ``enabled_actions`` is an empty list. + see more: https://resend.com/docs/api-reference/inboxes/get-agent + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + + Returns: + InboxAgentSettings: The agent settings of the inbox + """ + path = f"/inboxes/{inbox_id}/agent" + resp = request.Request[InboxAgentSettings]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def update(cls, params: UpdateParams) -> UpdateResponse: + """ + Update the agent settings of an inbox. The agent is created when the + inbox has none. Omitted fields keep their current value. + see more: https://resend.com/docs/api-reference/inboxes/update-agent + + Args: + params (UpdateParams): The inbox ID and the settings to change + + Returns: + UpdateResponse: The object type and the ID of the inbox agent + """ + path = f"/inboxes/{params['inbox_id']}/agent" + resp = request.Request[InboxAgent.UpdateResponse]( + path=path, params=without_keys(params, "inbox_id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, inbox_id: str) -> InboxAgentSettings: + """ + Retrieve the agent settings of an inbox (async). An inbox without a + configured agent returns empty settings: ``instructions`` and ``tone`` + are None and ``enabled_actions`` is an empty list. + see more: https://resend.com/docs/api-reference/inboxes/get-agent + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + + Returns: + InboxAgentSettings: The agent settings of the inbox + """ + path = f"/inboxes/{inbox_id}/agent" + resp = await AsyncRequest[InboxAgentSettings]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Update the agent settings of an inbox (async). The agent is created when + the inbox has none. Omitted fields keep their current value. + see more: https://resend.com/docs/api-reference/inboxes/update-agent + + Args: + params (UpdateParams): The inbox ID and the settings to change + + Returns: + UpdateResponse: The object type and the ID of the inbox agent + """ + path = f"/inboxes/{params['inbox_id']}/agent" + resp = await AsyncRequest[InboxAgent.UpdateResponse]( + path=path, params=without_keys(params, "inbox_id"), verb="patch" + ).perform_with_content() + return resp diff --git a/resend/inboxes/agent/_inbox_agent_settings.py b/resend/inboxes/agent/_inbox_agent_settings.py new file mode 100644 index 0000000..72b746c --- /dev/null +++ b/resend/inboxes/agent/_inbox_agent_settings.py @@ -0,0 +1,43 @@ +from typing import List, Optional + +from typing_extensions import Literal + +from resend._base_response import BaseResponse + +InboxAgentAction = Literal[ + "draft_reply", + "forward_thread", + "add_labels", + "assign_thread", + "mark_as_spam", + "archive_thread", + "delete_thread", +] +""" +An action the inbox agent can take. +""" + + +class InboxAgentSettings(BaseResponse): + """ + InboxAgentSettings are the agent settings of an inbox. + """ + + object: str + """ + The object type. Always ``inbox_agent``. + """ + instructions: Optional[str] + """ + Instructions the agent follows when it handles the threads of the inbox. + None when no instructions are set. + """ + tone: Optional[str] + """ + The tone the agent writes in, such as ``friendly and concise``. + None when no tone is set. + """ + enabled_actions: List[InboxAgentAction] + """ + The actions the agent can take. Empty when no actions are enabled. + """ diff --git a/resend/inboxes/drafts/__init__.py b/resend/inboxes/drafts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resend/inboxes/drafts/_inbox_draft.py b/resend/inboxes/drafts/_inbox_draft.py new file mode 100644 index 0000000..bf89844 --- /dev/null +++ b/resend/inboxes/drafts/_inbox_draft.py @@ -0,0 +1,124 @@ +from typing import List, Optional + +from typing_extensions import Literal, TypedDict + +from resend._base_response import BaseResponse + +InboxDraftType = Literal["standalone", "reply"] +""" +``reply`` when the draft replies to an email in an existing thread. +``standalone`` when the draft starts a new thread. +""" + + +class InboxDraft(BaseResponse): + """ + InboxDraft is an unsent or sent draft of an inbox. + """ + + object: str + """ + The object type. Always ``inbox_draft``. + """ + id: str + """ + The ID of the draft. + """ + type: InboxDraftType + """ + ``reply`` when the draft replies to an email in an existing thread. + ``standalone`` when the draft starts a new thread. + """ + to: Optional[List[str]] + """ + The recipients. None or empty when no recipients are set. + """ + cc: List[str] + """ + The CC recipients. + """ + bcc: List[str] + """ + The BCC recipients. + """ + subject: Optional[str] + """ + The subject. + """ + html: Optional[str] + """ + The HTML body. + """ + text: Optional[str] + """ + The plain text body. + """ + thread_id: Optional[str] + """ + The Thread ID when the draft is a reply. None otherwise. + """ + reply_to_email_id: Optional[str] + """ + The Email ID the draft replies to. None otherwise. + """ + email_id: Optional[str] + """ + The ID of the outbound email queued when the draft was sent. + None until the draft is sent. + """ + created_at: str + """ + Timestamp when the draft was created. + """ + updated_at: str + """ + Timestamp when the draft was last saved. + """ + + +class InboxDraftListItem(TypedDict): + """ + InboxDraftListItem is a draft as it appears in the list of drafts. + """ + + id: str + """ + The ID of the draft. + """ + type: InboxDraftType + """ + ``reply`` when the draft replies to an email in an existing thread. + ``standalone`` when the draft starts a new thread. + """ + to: Optional[List[str]] + """ + The recipients. None or empty when no recipients are set. + """ + cc: List[str] + """ + The CC recipients. + """ + bcc: List[str] + """ + The BCC recipients. + """ + subject: Optional[str] + """ + The subject. + """ + snippet: Optional[str] + """ + A short preview of the body. + """ + thread_id: Optional[str] + """ + The Thread ID when the draft is a reply. None otherwise. + """ + reply_to_email_id: Optional[str] + """ + The Email ID the draft replies to. None otherwise. + """ + updated_at: str + """ + Timestamp when the draft was last saved. + """ diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py new file mode 100644 index 0000000..44aa35e --- /dev/null +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -0,0 +1,499 @@ +from typing import Any, Dict, List, Optional, Union, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.inboxes._query import build_inbox_query_path, without_keys + +from ._inbox_draft import InboxDraft, InboxDraftListItem + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class InboxDrafts: + """ + InboxDrafts manages the drafts of an inbox. + """ + + class ListParams(TypedDict): + """ + ListParams are the pagination parameters for listing drafts. + The ``after`` and ``before`` cursors take a Draft ID from the current page. + + Attributes: + limit (NotRequired[int]): Number of drafts to retrieve. Maximum is 100, minimum is 1. + after (NotRequired[str]): The Draft ID after which to retrieve more drafts. + before (NotRequired[str]): The Draft ID before which to retrieve more drafts. + """ + + limit: NotRequired[int] + """ + Number of drafts to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The Draft ID after which we'll retrieve more drafts (for pagination). + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The Draft ID before which we'll retrieve more drafts (for pagination). + Cannot be used with the after parameter. + """ + + class ListResponse(BaseResponse): + """ + ListResponse is a page of the unsent drafts on an inbox. + + Attributes: + object (str): The object type, always "list" + has_more (bool): Whether there are more results available + data (List[InboxDraftListItem]): The unsent drafts, most recently updated first + """ + + object: str + """ + The object type, always "list". + """ + has_more: bool + """ + Whether there are more results available for pagination. + """ + data: List[InboxDraftListItem] + """ + The unsent drafts on this page, most recently updated first. + """ + + class CreateParams(TypedDict): + """ + CreateParams are the parameters for creating a draft. At least one of + ``to``, ``cc``, ``bcc``, ``subject``, ``text`` or ``html`` must be non-empty. + The combined recipients of ``to``, ``cc`` and ``bcc`` cannot exceed 50. + ``thread_id`` and ``reply_to_email_id`` must be sent together or not at all. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + to (NotRequired[Optional[Union[str, List[str]]]]): The recipients. + cc (NotRequired[Optional[Union[str, List[str]]]]): The CC recipients. + bcc (NotRequired[Optional[Union[str, List[str]]]]): The BCC recipients. + subject (NotRequired[Optional[str]]): The subject. + html (NotRequired[Optional[str]]): The HTML body. + text (NotRequired[Optional[str]]): The plain text body. + thread_id (NotRequired[str]): The Thread ID to reply in. + reply_to_email_id (NotRequired[str]): The Email ID to reply to. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + to: NotRequired[Optional[Union[str, List[str]]]] + """ + The recipients. For multiple addresses, send a list of strings. Each value + can include a friendly name, for example ``Ada Lovelace ``. + """ + cc: NotRequired[Optional[Union[str, List[str]]]] + """ + The CC recipients. For multiple addresses, send a list of strings. + """ + bcc: NotRequired[Optional[Union[str, List[str]]]] + """ + The BCC recipients. For multiple addresses, send a list of strings. + """ + subject: NotRequired[Optional[str]] + """ + The subject. Max 2000 characters. A string that is only whitespace, or + that contains a line break, is rejected. + """ + html: NotRequired[Optional[str]] + """ + The HTML body. + """ + text: NotRequired[Optional[str]] + """ + The plain text body. + """ + thread_id: NotRequired[str] + """ + The Thread ID to reply in. Must be sent with ``reply_to_email_id``. + """ + reply_to_email_id: NotRequired[str] + """ + The Email ID to reply to, as returned in ``data[].id`` by the list thread + emails endpoint. Must be sent with ``thread_id``. + """ + + class CreateResponse(BaseResponse): + """ + CreateResponse is the response of creating a draft. + + Attributes: + object (str): The object type, always "inbox_draft" + id (str): The ID of the draft + """ + + object: str + """ + The object type, always "inbox_draft". + """ + id: str + """ + The ID of the draft. + """ + + class UpdateParams(TypedDict): + """ + UpdateParams are the parameters for updating a draft. Send at least one of + ``to``, ``cc``, ``bcc``, ``subject``, ``text`` or ``html``. Omitted fields + are left unchanged. Setting ``text`` or ``html`` replaces the whole body, so + the body part you omit becomes None. The merged draft must contain at least + one non-empty field, and its combined recipients cannot exceed 50. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + id (str): The Draft ID. + to (NotRequired[Optional[Union[str, List[str]]]]): The recipients. + cc (NotRequired[Optional[Union[str, List[str]]]]): The CC recipients. + bcc (NotRequired[Optional[Union[str, List[str]]]]): The BCC recipients. + subject (NotRequired[Optional[str]]): The subject. + html (NotRequired[Optional[str]]): The HTML body. + text (NotRequired[Optional[str]]): The plain text body. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + id: str + """ + The Draft ID. + """ + to: NotRequired[Optional[Union[str, List[str]]]] + """ + The recipients. For multiple addresses, send a list of strings. Each value + can include a friendly name. Pass None to clear. + """ + cc: NotRequired[Optional[Union[str, List[str]]]] + """ + The CC recipients. Pass None or ``[]`` to clear. + """ + bcc: NotRequired[Optional[Union[str, List[str]]]] + """ + The BCC recipients. Pass None or ``[]`` to clear. + """ + subject: NotRequired[Optional[str]] + """ + The subject. Max 2000 characters. Must not contain a line break. + Pass None to clear. + """ + html: NotRequired[Optional[str]] + """ + The HTML body. Pass None to clear. + """ + text: NotRequired[Optional[str]] + """ + The plain text body. Pass None to clear. + """ + + class RemoveResponse(BaseResponse): + """ + RemoveResponse is the response of removing a draft. + + Attributes: + object (str): The object type, always "inbox_draft" + id (str): The ID of the draft + deleted (bool): Whether the draft was deleted + """ + + object: str + """ + The object type, always "inbox_draft". + """ + id: str + """ + The ID of the draft. + """ + deleted: bool + """ + Indicates whether the draft was successfully deleted. + """ + + class SendResponse(BaseResponse): + """ + SendResponse is the response of sending a draft. + + Attributes: + id (str): The ID of the sent email + """ + + id: str + """ + The ID of the sent email. Use it with the emails endpoints. + """ + + @classmethod + def list(cls, inbox_id: str, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve the unsent drafts on an inbox, most recently updated first. + A cursor that is not an unsent draft on this inbox returns a 404. + see more: https://resend.com/docs/api-reference/inboxes/list-drafts + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A page of the unsent drafts + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/drafts", + cast(Dict[Any, Any], params) if params else None, + ) + resp = request.Request[InboxDrafts.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def create(cls, params: CreateParams) -> CreateResponse: + """ + Create an unsent draft on an inbox. Omit ``thread_id`` and + ``reply_to_email_id`` for a draft that starts a new thread. Pass both + together for a reply draft in an existing thread. A reply draft without + ``to`` is addressed to the sender of the email it replies to, or to its + Reply-To when set. For an email the inbox sent, it defaults to that email's + ``to`` recipients. Send ``to: []`` to leave it empty. Only one unsent reply + draft can target an email, so a reply draft request for an email that + already has one returns the ID of the existing draft. + see more: https://resend.com/docs/api-reference/inboxes/create-draft + + Args: + params (CreateParams): The inbox ID and the content of the draft + + Returns: + CreateResponse: The object type and the ID of the draft + """ + path = f"/inboxes/{params['inbox_id']}/drafts" + resp = request.Request[InboxDrafts.CreateResponse]( + path=path, params=without_keys(params, "inbox_id"), verb="post" + ).perform_with_content() + return resp + + @classmethod + def get(cls, inbox_id: str, draft_id: str) -> InboxDraft: + """ + Retrieve a single draft. + see more: https://resend.com/docs/api-reference/inboxes/get-draft + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + draft_id (str): The Draft ID. + + Returns: + InboxDraft: The draft + """ + path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + resp = request.Request[InboxDraft]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def update(cls, params: UpdateParams) -> InboxDraft: + """ + Update the content of an unsent draft. Omitted fields are left unchanged. + A request that sets ``text`` or ``html`` replaces the whole body. The body + part you omit from that request becomes None. A draft that was already + sent, or that changed during the request, returns a 409. + see more: https://resend.com/docs/api-reference/inboxes/update-draft + + Args: + params (UpdateParams): The inbox ID, the draft ID and the fields to change + + Returns: + InboxDraft: The updated draft + """ + path = f"/inboxes/{params['inbox_id']}/drafts/{params['id']}" + resp = request.Request[InboxDraft]( + path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + def remove(cls, inbox_id: str, draft_id: str) -> RemoveResponse: + """ + Discard an unsent draft. A draft that was already sent, or that changed + during the request, returns a 409. + see more: https://resend.com/docs/api-reference/inboxes/delete-draft + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + draft_id (str): The Draft ID. + + Returns: + RemoveResponse: The draft ID and its deleted status + """ + path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + resp = request.Request[InboxDrafts.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + def send(cls, inbox_id: str, draft_id: str) -> SendResponse: + """ + Send a draft from the inbox address. A reply draft is sent in its thread. + A draft without a thread starts a new thread. The draft must have at least + one ``to`` recipient, or the request returns a 422. A draft that was + already sent returns a 409. + see more: https://resend.com/docs/api-reference/inboxes/send-draft + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + draft_id (str): The Draft ID. + + Returns: + SendResponse: The ID of the sent email + """ + path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" + resp = request.Request[InboxDrafts.SendResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, inbox_id: str, params: Optional[ListParams] = None + ) -> ListResponse: + """ + Retrieve the unsent drafts on an inbox, most recently updated first (async). + A cursor that is not an unsent draft on this inbox returns a 404. + see more: https://resend.com/docs/api-reference/inboxes/list-drafts + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A page of the unsent drafts + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/drafts", + cast(Dict[Any, Any], params) if params else None, + ) + resp = await AsyncRequest[InboxDrafts.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """ + Create an unsent draft on an inbox (async). Omit ``thread_id`` and + ``reply_to_email_id`` for a draft that starts a new thread. Pass both + together for a reply draft in an existing thread. A reply draft without + ``to`` is addressed to the sender of the email it replies to, or to its + Reply-To when set. For an email the inbox sent, it defaults to that email's + ``to`` recipients. Send ``to: []`` to leave it empty. Only one unsent reply + draft can target an email, so a reply draft request for an email that + already has one returns the ID of the existing draft. + see more: https://resend.com/docs/api-reference/inboxes/create-draft + + Args: + params (CreateParams): The inbox ID and the content of the draft + + Returns: + CreateResponse: The object type and the ID of the draft + """ + path = f"/inboxes/{params['inbox_id']}/drafts" + resp = await AsyncRequest[InboxDrafts.CreateResponse]( + path=path, params=without_keys(params, "inbox_id"), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, inbox_id: str, draft_id: str) -> InboxDraft: + """ + Retrieve a single draft (async). + see more: https://resend.com/docs/api-reference/inboxes/get-draft + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + draft_id (str): The Draft ID. + + Returns: + InboxDraft: The draft + """ + path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + resp = await AsyncRequest[InboxDraft]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> InboxDraft: + """ + Update the content of an unsent draft (async). Omitted fields are left + unchanged. A request that sets ``text`` or ``html`` replaces the whole body. + The body part you omit from that request becomes None. A draft that was + already sent, or that changed during the request, returns a 409. + see more: https://resend.com/docs/api-reference/inboxes/update-draft + + Args: + params (UpdateParams): The inbox ID, the draft ID and the fields to change + + Returns: + InboxDraft: The updated draft + """ + path = f"/inboxes/{params['inbox_id']}/drafts/{params['id']}" + resp = await AsyncRequest[InboxDraft]( + path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, inbox_id: str, draft_id: str) -> RemoveResponse: + """ + Discard an unsent draft (async). A draft that was already sent, or that + changed during the request, returns a 409. + see more: https://resend.com/docs/api-reference/inboxes/delete-draft + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + draft_id (str): The Draft ID. + + Returns: + RemoveResponse: The draft ID and its deleted status + """ + path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + resp = await AsyncRequest[InboxDrafts.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def send_async(cls, inbox_id: str, draft_id: str) -> SendResponse: + """ + Send a draft from the inbox address (async). A reply draft is sent in its + thread. A draft without a thread starts a new thread. The draft must have + at least one ``to`` recipient, or the request returns a 422. A draft that + was already sent returns a 409. + see more: https://resend.com/docs/api-reference/inboxes/send-draft + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + draft_id (str): The Draft ID. + + Returns: + SendResponse: The ID of the sent email + """ + path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" + resp = await AsyncRequest[InboxDrafts.SendResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + return resp diff --git a/resend/inboxes/labels/__init__.py b/resend/inboxes/labels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resend/inboxes/labels/_inbox_label.py b/resend/inboxes/labels/_inbox_label.py new file mode 100644 index 0000000..6def41f --- /dev/null +++ b/resend/inboxes/labels/_inbox_label.py @@ -0,0 +1,24 @@ +from typing_extensions import TypedDict + + +class InboxLabel(TypedDict): + """ + InboxLabel is a named, colored tag that belongs to one inbox. + """ + + id: str + """ + The ID of the label. + """ + name: str + """ + The name of the label. + """ + color: str + """ + The color of the label, as a ``#RRGGBB`` hex color in uppercase. + """ + created_at: str + """ + Timestamp when the label was created. + """ diff --git a/resend/inboxes/labels/_inbox_labels.py b/resend/inboxes/labels/_inbox_labels.py new file mode 100644 index 0000000..f5d34a4 --- /dev/null +++ b/resend/inboxes/labels/_inbox_labels.py @@ -0,0 +1,309 @@ +from typing import List + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.inboxes._query import without_keys + +from ._inbox_label import InboxLabel + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class InboxLabels: + """ + InboxLabels manages the labels of an inbox. A label is a named, colored tag + that belongs to one inbox. + """ + + class ListResponse(BaseResponse): + """ + ListResponse is the list of labels on an inbox. The list is not paginated. + + Attributes: + object (str): The object type, always "list" + data (List[InboxLabel]): The labels on the inbox, oldest first + """ + + object: str + """ + The object type, always "list". + """ + data: List[InboxLabel] + """ + The labels on the inbox, oldest first. + """ + + class CreateParams(TypedDict): + """ + CreateParams are the parameters for creating a label on an inbox. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + name (str): The name of the label. + color (NotRequired[str]): The color of the label, as a #RRGGBB hex color. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + name: str + """ + The name of the label. Max 64 characters. Leading and trailing whitespace + is trimmed. Must not match another label on the inbox, compared without case. + """ + color: NotRequired[str] + """ + The color of the label, as a ``#RRGGBB`` hex color. It is stored in uppercase. + When omitted, a random color from the dashboard palette is used. + """ + + class CreateResponse(BaseResponse): + """ + CreateResponse is the response of creating a label. + + Attributes: + object (str): The object type, always "inbox_label" + id (str): The ID of the label + """ + + object: str + """ + The object type, always "inbox_label". + """ + id: str + """ + The ID of the label. + """ + + class UpdateParams(TypedDict): + """ + UpdateParams are the parameters for updating a label. + Omitted fields keep their current value. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + id (str): The Label ID. + name (NotRequired[str]): A new name for the label. + color (NotRequired[str]): A new color for the label, as a #RRGGBB hex color. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + id: str + """ + The Label ID. + """ + name: NotRequired[str] + """ + A new name for the label. Max 64 characters. Leading and trailing whitespace + is trimmed. Must not match another label on the inbox, compared without case. + """ + color: NotRequired[str] + """ + A new color for the label, as a ``#RRGGBB`` hex color. It is stored in uppercase. + """ + + class UpdateResponse(BaseResponse): + """ + UpdateResponse is the response of updating a label. + + Attributes: + object (str): The object type, always "inbox_label" + id (str): The ID of the updated label + """ + + object: str + """ + The object type, always "inbox_label". + """ + id: str + """ + The ID of the updated label. + """ + + class RemoveResponse(BaseResponse): + """ + RemoveResponse is the response of removing a label. + + Attributes: + object (str): The object type, always "inbox_label" + id (str): The ID of the removed label + deleted (bool): Whether the label was deleted + """ + + object: str + """ + The object type, always "inbox_label". + """ + id: str + """ + The ID of the removed label. + """ + deleted: bool + """ + Indicates whether the label was deleted. + """ + + @classmethod + def list(cls, inbox_id: str) -> ListResponse: + """ + Retrieve every label on an inbox, oldest first. The list is not paginated. + see more: https://resend.com/docs/api-reference/inboxes/list-labels + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + + Returns: + ListResponse: The labels on the inbox + """ + path = f"/inboxes/{inbox_id}/labels" + resp = request.Request[InboxLabels.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def create(cls, params: CreateParams) -> CreateResponse: + """ + Create a label on an inbox. An inbox can have at most 100 labels. Label + names are unique per inbox, compared without case. A duplicate name or a + full inbox returns a 422 validation_error. + see more: https://resend.com/docs/api-reference/inboxes/create-label + + Args: + params (CreateParams): The inbox ID, the label name and an optional color + + Returns: + CreateResponse: The object type and the ID of the label + """ + path = f"/inboxes/{params['inbox_id']}/labels" + resp = request.Request[InboxLabels.CreateResponse]( + path=path, params=without_keys(params, "inbox_id"), verb="post" + ).perform_with_content() + return resp + + @classmethod + def update(cls, params: UpdateParams) -> UpdateResponse: + """ + Rename or recolor a label. Omitted fields keep their current value. A new + name must not match another label on the inbox, compared without case, or + the request returns a 422 validation_error. + see more: https://resend.com/docs/api-reference/inboxes/update-label + + Args: + params (UpdateParams): The inbox ID, the label ID and the fields to change + + Returns: + UpdateResponse: The object type and the ID of the updated label + """ + path = f"/inboxes/{params['inbox_id']}/labels/{params['id']}" + resp = request.Request[InboxLabels.UpdateResponse]( + path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + def remove(cls, inbox_id: str, label_id: str) -> RemoveResponse: + """ + Delete a label and remove it from every thread that carries it. + see more: https://resend.com/docs/api-reference/inboxes/delete-label + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + label_id (str): The Label ID. + + Returns: + RemoveResponse: The removed label ID and its deleted status + """ + path = f"/inboxes/{inbox_id}/labels/{label_id}" + resp = request.Request[InboxLabels.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, inbox_id: str) -> ListResponse: + """ + Retrieve every label on an inbox, oldest first (async). + The list is not paginated. + see more: https://resend.com/docs/api-reference/inboxes/list-labels + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + + Returns: + ListResponse: The labels on the inbox + """ + path = f"/inboxes/{inbox_id}/labels" + resp = await AsyncRequest[InboxLabels.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def create_async(cls, params: CreateParams) -> CreateResponse: + """ + Create a label on an inbox (async). An inbox can have at most 100 labels. + Label names are unique per inbox, compared without case. A duplicate name + or a full inbox returns a 422 validation_error. + see more: https://resend.com/docs/api-reference/inboxes/create-label + + Args: + params (CreateParams): The inbox ID, the label name and an optional color + + Returns: + CreateResponse: The object type and the ID of the label + """ + path = f"/inboxes/{params['inbox_id']}/labels" + resp = await AsyncRequest[InboxLabels.CreateResponse]( + path=path, params=without_keys(params, "inbox_id"), verb="post" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> UpdateResponse: + """ + Rename or recolor a label (async). Omitted fields keep their current value. + A new name must not match another label on the inbox, compared without + case, or the request returns a 422 validation_error. + see more: https://resend.com/docs/api-reference/inboxes/update-label + + Args: + params (UpdateParams): The inbox ID, the label ID and the fields to change + + Returns: + UpdateResponse: The object type and the ID of the updated label + """ + path = f"/inboxes/{params['inbox_id']}/labels/{params['id']}" + resp = await AsyncRequest[InboxLabels.UpdateResponse]( + path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, inbox_id: str, label_id: str) -> RemoveResponse: + """ + Delete a label and remove it from every thread that carries it (async). + see more: https://resend.com/docs/api-reference/inboxes/delete-label + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + label_id (str): The Label ID. + + Returns: + RemoveResponse: The removed label ID and its deleted status + """ + path = f"/inboxes/{inbox_id}/labels/{label_id}" + resp = await AsyncRequest[InboxLabels.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/inboxes/threads/__init__.py b/resend/inboxes/threads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resend/inboxes/threads/_inbox_thread.py b/resend/inboxes/threads/_inbox_thread.py new file mode 100644 index 0000000..d6fbd6d --- /dev/null +++ b/resend/inboxes/threads/_inbox_thread.py @@ -0,0 +1,162 @@ +from typing import List, Optional + +from typing_extensions import NotRequired, TypedDict + +from resend._base_response import BaseResponse +from resend.inboxes._inbox import InboxMessageFolder + + +class InboxThreadLabel(TypedDict): + """ + InboxThreadLabel is a label attached to a thread. + """ + + id: str + """ + The ID of the label. + """ + name: str + """ + The name of the label. + """ + color: str + """ + The color of the label, as a ``#RRGGBB`` hex color in uppercase. + """ + + +# Functional syntax required because ``from`` is a reserved keyword. +_InboxThreadFrom = TypedDict( + "_InboxThreadFrom", + { + "from": Optional[str], + }, +) + + +class InboxThread(_InboxThreadFrom): + """ + InboxThread is a thread as it appears in the list of an inbox's threads. + The ``from`` key holds the sender email address, and can include the display name. + """ + + id: str + """ + The ID of the thread. + """ + subject: Optional[str] + """ + The subject of the thread. + """ + to: List[str] + """ + The recipients of the most recent email. For a received email, these are + the addresses it was delivered to, not the full ``To`` header. + """ + cc: List[str] + """ + The CC recipients of the most recent email. + """ + bcc: List[str] + """ + The BCC recipients of the most recent email. Always empty for a received email. + """ + labels: List[InboxThreadLabel] + """ + The labels attached to the thread. + """ + message_count: int + """ + The number of emails in the thread. + """ + has_attachment: bool + """ + Whether the most recent email in the thread has an attachment. + """ + has_draft: bool + """ + Whether the thread has an unsent draft. + """ + read: bool + """ + True only when every email in the thread is read. + """ + received_at: str + """ + Timestamp when the thread was last active. + """ + folder: InboxMessageFolder + """ + The folder the thread is in. Every thread is in exactly one folder. + """ + + +# Functional syntax required because ``from`` is a reserved keyword. +InboxThreadSearchHighlights = TypedDict( + "InboxThreadSearchHighlights", + { + "subject": NotRequired[List[str]], + "body": NotRequired[List[str]], + "from": NotRequired[List[str]], + "to": NotRequired[List[str]], + "cc": NotRequired[List[str]], + "bcc": NotRequired[List[str]], + "attachments": NotRequired[List[str]], + }, +) +""" +Where ``query`` matched the email in ``matched_email_id``, as text fragments +with each match wrapped in ``**``. Only keys with a match are present. +""" + + +class InboxThreadSearchResult(InboxThread): + """ + InboxThreadSearchResult is a thread returned by the search threads endpoint. + """ + + matched_email_id: Optional[str] + """ + Identifies the email in the thread that matched the search. When ``query`` + has words or phrases, it is the most relevant match. Otherwise, it is the + newest. The get, reply and forward thread email endpoints accept it as + ``email_id``, but it is not equal to the ``id`` those endpoints return. + None when the search index returns no matched email for the thread. + """ + highlights: InboxThreadSearchHighlights + """ + Where ``query`` matched the email in ``matched_email_id``, as text fragments + with each match wrapped in ``**``. Only keys with a match are present. + An empty dict when there is no ``query``. + """ + + +class InboxThreadSummary(BaseResponse): + """ + InboxThreadSummary is the summary of a single thread. + """ + + object: str + """ + The object type. Always ``inbox_thread``. + """ + id: str + """ + The ID of the thread. + """ + subject: Optional[str] + """ + The subject of the thread. + """ + folder: InboxMessageFolder + """ + The folder the thread is in. + """ + labels: List[InboxThreadLabel] + """ + The labels attached to the thread. + """ + read: bool + """ + True only when every email in the thread is read. + """ diff --git a/resend/inboxes/threads/_inbox_threads.py b/resend/inboxes/threads/_inbox_threads.py new file mode 100644 index 0000000..2add23a --- /dev/null +++ b/resend/inboxes/threads/_inbox_threads.py @@ -0,0 +1,505 @@ +from typing import Any, Dict, List, Optional, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.inboxes._inbox import InboxMessageFolder, MoveThreadFolder +from resend.inboxes._query import build_inbox_query_path, without_keys + +from ._inbox_thread import (InboxThread, InboxThreadSearchResult, + InboxThreadSummary) +from .emails._inbox_thread_emails import InboxThreadEmails + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class _ListParams(TypedDict): + folders: NotRequired[List[InboxMessageFolder]] + """ + Folders to list, for example ``["inbox", "sent"]``. A thread is returned when + any of its emails is in one of the folders, so a thread in ``inbox`` with an + email you sent matches both ``inbox`` and ``sent``. Defaults to ``inbox``, or + to ``inbox,archive,sent`` when ``labels`` is set. ``spam`` and ``trash`` are + only included when you name them. + """ + labels: NotRequired[List[str]] + """ + Label IDs. Returns threads with any of these labels. Up to 50 IDs. + An ID that does not exist in the inbox returns a 404. + """ + read: NotRequired[bool] + """ + True returns threads where every email is read. False returns threads with + at least one unread email. + """ + limit: NotRequired[int] + """ + Number of threads to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The thread ID after which we'll retrieve more threads (for pagination). + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The thread ID before which we'll retrieve more threads (for pagination). + Cannot be used with the after parameter. + """ + + +# Functional syntax required because ``from`` is a reserved keyword. +_SearchParamsFrom = TypedDict( + "_SearchParamsFrom", + { + "from": NotRequired[List[str]], + }, +) + + +class _SearchParams(_ListParams, _SearchParamsFrom): + query: NotRequired[str] + """ + Free text to search for in the subject, body, sender, recipients, and + attachment names. Every word must match. Wrap words in double quotes to match + an exact phrase. Start a word or quoted phrase with ``-`` to exclude it. + Max 256 characters and 32 words. + """ + to: NotRequired[List[str]] + """ + Recipients, matched the same way as ``from``. Up to 20 values. + """ + cc: NotRequired[List[str]] + """ + CC recipients, matched the same way as ``from``. Up to 20 values. + """ + bcc: NotRequired[List[str]] + """ + BCC recipients, matched the same way as ``from``. Up to 20 values. + """ + has_attachment: NotRequired[bool] + """ + True matches emails with an attachment. False matches emails without one. + """ + start_date: NotRequired[str] + """ + Matches emails sent on or after this date. Accepts a date like ``2026-09-01`` + or an ISO 8601 timestamp, in UTC unless the timestamp has an offset. + """ + end_date: NotRequired[str] + """ + Matches emails sent on or before this date. Same formats as ``start_date``. + A date covers that whole day. A timestamp is used as given. An ``end_date`` + before ``start_date`` returns a 422 validation_error. + """ + + +class InboxThreads: + """ + InboxThreads manages the threads of an inbox. A thread is a conversation + made up of one or more emails. + """ + + class Emails(InboxThreadEmails): + pass + + class ListParams(_ListParams): + """ + ListParams are the filter and pagination parameters for listing threads. + + Attributes: + folders (NotRequired[List[InboxMessageFolder]]): Folders to list. + labels (NotRequired[List[str]]): Label IDs. Returns threads with any of these labels. + read (NotRequired[bool]): Filter by read state. + limit (NotRequired[int]): Number of threads to retrieve. Maximum is 100, minimum is 1. + after (NotRequired[str]): The thread ID after which to retrieve more threads. + before (NotRequired[str]): The thread ID before which to retrieve more threads. + """ + + class SearchParams(_SearchParams): + """ + SearchParams are the search, filter and pagination parameters for searching + threads. Different parameters combine, so a thread must match all of them. + ``folders``, ``labels``, and ``read`` apply to the thread. ``query``, + ``from``, ``to``, ``cc``, ``bcc``, ``has_attachment``, ``start_date``, and + ``end_date`` must all match the same email in the thread. + + Attributes: + query (NotRequired[str]): Free text to search for. + from (NotRequired[List[str]]): Senders. Each value matches part of the + sender's address or name, without case. Up to 20 values. + to (NotRequired[List[str]]): Recipients. + cc (NotRequired[List[str]]): CC recipients. + bcc (NotRequired[List[str]]): BCC recipients. + has_attachment (NotRequired[bool]): Filter by attachment. + start_date (NotRequired[str]): Matches emails sent on or after this date. + end_date (NotRequired[str]): Matches emails sent on or before this date. + folders (NotRequired[List[InboxMessageFolder]]): Folders to search. + labels (NotRequired[List[str]]): Label IDs. + read (NotRequired[bool]): Filter by read state. + limit (NotRequired[int]): Number of threads to retrieve. + after (NotRequired[str]): The thread ID after which to retrieve more threads. + before (NotRequired[str]): The thread ID before which to retrieve more threads. + """ + + class ListResponse(BaseResponse): + """ + ListResponse is a page of the threads of an inbox. + + Attributes: + object (str): The object type, always "list" + has_more (bool): Whether there are more results available + data (List[InboxThread]): The matching threads, most recently active first + """ + + object: str + """ + The object type, always "list". + """ + has_more: bool + """ + Whether there are more results available for pagination. + """ + data: List[InboxThread] + """ + The matching threads, most recently active first. + """ + + class SearchResponse(BaseResponse): + """ + SearchResponse is a page of the threads that match a search. + + Attributes: + object (str): The object type, always "list" + has_more (bool): Whether there are more results available + data (List[InboxThreadSearchResult]): The matching threads, most recently active first + """ + + object: str + """ + The object type, always "list". + """ + has_more: bool + """ + Whether there are more results available for pagination. + """ + data: List[InboxThreadSearchResult] + """ + The matching threads, most recently active first. + """ + + class UpdateParams(TypedDict): + """ + UpdateParams are the parameters for updating a thread. At least one of + ``read``, ``folder``, ``label_id``, ``add_label_ids``, or ``remove_label_ids`` + is required. A label ID cannot be both added and removed in the same request. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + id (str): The Thread ID. + read (NotRequired[bool]): Marks every email in the thread read or unread. + folder (NotRequired[MoveThreadFolder]): Moves the thread to this folder. + label_id (NotRequired[str]): Deprecated. Use add_label_ids instead. + add_label_ids (NotRequired[List[str]]): Label IDs to apply to the thread. + remove_label_ids (NotRequired[List[str]]): Label IDs to remove from the thread. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + id: str + """ + The Thread ID. + """ + read: NotRequired[bool] + """ + Marks every email in the thread read or unread. + """ + folder: NotRequired[MoveThreadFolder] + """ + Moves the thread to this folder. ``sent`` is assigned automatically and + cannot be set. Moving a thread out of ``trash`` restores it and clears its + deletion deadline. + """ + label_id: NotRequired[str] + """ + Deprecated. Use ``add_label_ids`` instead. Applies this label to the + thread, the same as one entry in ``add_label_ids``. A label ID that does + not exist in the inbox returns a 404. + """ + add_label_ids: NotRequired[List[str]] + """ + Label IDs to apply to the thread. Between 1 and 10 IDs. + A label ID that does not exist in the inbox returns a 404. + """ + remove_label_ids: NotRequired[List[str]] + """ + Label IDs to remove from the thread. Between 1 and 10 IDs. + A label ID that does not exist in the inbox returns a 404. + """ + + class RemoveResponse(BaseResponse): + """ + RemoveResponse is the response of removing a thread. + + Attributes: + object (str): The object type, always "inbox_thread" + id (str): The ID of the thread + deleted (bool): True when this call moved the thread to trash + """ + + object: str + """ + The object type, always "inbox_thread". + """ + id: str + """ + The ID of the thread. + """ + deleted: bool + """ + True when this call moved the thread to ``trash``. + False when the thread was already there. + """ + + @classmethod + def list(cls, inbox_id: str, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve the threads in an inbox, most recently active first. The ``after`` + and ``before`` cursors take a thread ID, and only one of them can be passed. + To find threads by text, sender, recipient, attachment, or date, use ``search``. + see more: https://resend.com/docs/api-reference/inboxes/list-threads + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + params (Optional[ListParams]): Optional filter and pagination parameters + + Returns: + ListResponse: A page of the threads of the inbox + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/threads", + cast(Dict[Any, Any], params) if params else None, + ) + resp = request.Request[InboxThreads.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def search( + cls, inbox_id: str, params: Optional[SearchParams] = None + ) -> SearchResponse: + """ + Find threads by the text of their emails, who sent or received them, + whether they have attachments, and their dates. Results are ordered by + latest activity, newest first, and paginate like ``list``. Search can be + up to about a minute behind new emails. With a ``query``, ``folders=sent`` + also requires the matching email to be one the inbox sent. When more than + 10,000 threads match, search covers the 10,000 with the newest matching email. + see more: https://resend.com/docs/api-reference/inboxes/search-threads + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + params (Optional[SearchParams]): Optional search, filter and pagination parameters + + Returns: + SearchResponse: A page of the matching threads + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/threads/search", + cast(Dict[Any, Any], params) if params else None, + ) + resp = request.Request[InboxThreads.SearchResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def get(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: + """ + Retrieve the summary of a thread. The emails in the thread are listed + with ``Inboxes.Threads.Emails.list``. + see more: https://resend.com/docs/api-reference/inboxes/get-thread + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + + Returns: + InboxThreadSummary: The thread summary + """ + path = f"/inboxes/{inbox_id}/threads/{thread_id}" + resp = request.Request[InboxThreadSummary]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def update(cls, params: UpdateParams) -> InboxThreadSummary: + """ + Mark a thread read or unread, move it to a folder, or add and remove labels. + At least one of ``read``, ``folder``, ``label_id``, ``add_label_ids``, or + ``remove_label_ids`` is required. A label ID cannot be both added and + removed in the same request. Moving a thread out of ``trash`` restores it + and clears its deletion deadline. + see more: https://resend.com/docs/api-reference/inboxes/update-thread + + Args: + params (UpdateParams): The inbox ID, the thread ID and the changes + + Returns: + InboxThreadSummary: The updated thread summary + """ + path = f"/inboxes/{params['inbox_id']}/threads/{params['id']}" + resp = request.Request[InboxThreadSummary]( + path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + def remove(cls, inbox_id: str, thread_id: str) -> RemoveResponse: + """ + Move a thread to ``trash`` and schedule it for deletion in 30 days. This is + reversible. To restore the thread, update its ``folder`` to ``inbox``, + ``archive``, or ``spam``, which also clears the deletion deadline. + see more: https://resend.com/docs/api-reference/inboxes/delete-thread + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + + Returns: + RemoveResponse: The thread ID and whether this call moved it to trash + """ + path = f"/inboxes/{inbox_id}/threads/{thread_id}" + resp = request.Request[InboxThreads.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, inbox_id: str, params: Optional[ListParams] = None + ) -> ListResponse: + """ + Retrieve the threads in an inbox, most recently active first (async). + The ``after`` and ``before`` cursors take a thread ID, and only one of them + can be passed. To find threads by text, sender, recipient, attachment, or + date, use ``search_async``. + see more: https://resend.com/docs/api-reference/inboxes/list-threads + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + params (Optional[ListParams]): Optional filter and pagination parameters + + Returns: + ListResponse: A page of the threads of the inbox + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/threads", + cast(Dict[Any, Any], params) if params else None, + ) + resp = await AsyncRequest[InboxThreads.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def search_async( + cls, inbox_id: str, params: Optional[SearchParams] = None + ) -> SearchResponse: + """ + Find threads by the text of their emails, who sent or received them, + whether they have attachments, and their dates (async). Results are + ordered by latest activity, newest first, and paginate like ``list``. + Search can be up to about a minute behind new emails. With a ``query``, + ``folders=sent`` also requires the matching email to be one the inbox + sent. When more than 10,000 threads match, search covers the 10,000 with + the newest matching email. + see more: https://resend.com/docs/api-reference/inboxes/search-threads + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + params (Optional[SearchParams]): Optional search, filter and pagination parameters + + Returns: + SearchResponse: A page of the matching threads + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/threads/search", + cast(Dict[Any, Any], params) if params else None, + ) + resp = await AsyncRequest[InboxThreads.SearchResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: + """ + Retrieve the summary of a thread (async). The emails in the thread are + listed with ``Inboxes.Threads.Emails.list_async``. + see more: https://resend.com/docs/api-reference/inboxes/get-thread + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + + Returns: + InboxThreadSummary: The thread summary + """ + path = f"/inboxes/{inbox_id}/threads/{thread_id}" + resp = await AsyncRequest[InboxThreadSummary]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def update_async(cls, params: UpdateParams) -> InboxThreadSummary: + """ + Mark a thread read or unread, move it to a folder, or add and remove + labels (async). At least one of ``read``, ``folder``, ``label_id``, + ``add_label_ids``, or ``remove_label_ids`` is required. A label ID cannot + be both added and removed in the same request. Moving a thread out of + ``trash`` restores it and clears its deletion deadline. + see more: https://resend.com/docs/api-reference/inboxes/update-thread + + Args: + params (UpdateParams): The inbox ID, the thread ID and the changes + + Returns: + InboxThreadSummary: The updated thread summary + """ + path = f"/inboxes/{params['inbox_id']}/threads/{params['id']}" + resp = await AsyncRequest[InboxThreadSummary]( + path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" + ).perform_with_content() + return resp + + @classmethod + async def remove_async(cls, inbox_id: str, thread_id: str) -> RemoveResponse: + """ + Move a thread to ``trash`` and schedule it for deletion in 30 days (async). + This is reversible. To restore the thread, update its ``folder`` to + ``inbox``, ``archive``, or ``spam``, which also clears the deletion deadline. + see more: https://resend.com/docs/api-reference/inboxes/delete-thread + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + + Returns: + RemoveResponse: The thread ID and whether this call moved it to trash + """ + path = f"/inboxes/{inbox_id}/threads/{thread_id}" + resp = await AsyncRequest[InboxThreads.RemoveResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/inboxes/threads/emails/__init__.py b/resend/inboxes/threads/emails/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resend/inboxes/threads/emails/_inbox_thread_email.py b/resend/inboxes/threads/emails/_inbox_thread_email.py new file mode 100644 index 0000000..9e43c7f --- /dev/null +++ b/resend/inboxes/threads/emails/_inbox_thread_email.py @@ -0,0 +1,96 @@ +from typing import List, Optional + +from typing_extensions import Literal, TypedDict + +from resend._base_response import BaseResponse + + +class InboxThreadEmailAttachment(TypedDict): + """ + InboxThreadEmailAttachment is an attachment of a thread email. + """ + + id: str + """ + The ID of the attachment. + """ + filename: Optional[str] + """ + The filename of the attachment. + """ + size: Optional[int] + """ + The size of the attachment in bytes. + """ + + +# Functional syntax required because ``from`` is a reserved keyword. +_InboxThreadEmailFrom = TypedDict( + "_InboxThreadEmailFrom", + { + "from": str, + }, +) + + +class InboxThreadEmail(_InboxThreadEmailFrom, BaseResponse): + """ + InboxThreadEmail is a message of an inbox thread. + The ``from`` key holds the sender email address. It includes the display + name when the sender has one, in the format "Name ". + """ + + id: str + """ + The ID of the message. + """ + direction: Literal["inbound", "outbound"] + """ + Whether the inbox received the message (``inbound``) or sent it (``outbound``). + A message the inbox sent to itself is ``inbound``, so a reply to it goes to its sender. + """ + to: List[str] + """ + The recipients of the message. For a received message, these are the + addresses it was delivered to, not the full ``To`` header. + """ + cc: List[str] + """ + The CC recipients of the message. + """ + bcc: List[str] + """ + The BCC recipients of the message. Always empty for a received message. + """ + reply_to: List[str] + """ + The Reply-To addresses of the message. + """ + subject: Optional[str] + """ + The subject of the message. + """ + message_id: Optional[str] + """ + The Message-ID header of the message. + """ + html: Optional[str] + """ + The HTML body. + """ + text: Optional[str] + """ + The plain-text body. + """ + attachments: List[InboxThreadEmailAttachment] + """ + The attachments of the message. + """ + read: bool + """ + Whether the message has been read. + """ + received_at: str + """ + Timestamp when the inbox received the message, or when the inbox sent it. + """ diff --git a/resend/inboxes/threads/emails/_inbox_thread_emails.py b/resend/inboxes/threads/emails/_inbox_thread_emails.py new file mode 100644 index 0000000..68a092f --- /dev/null +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -0,0 +1,450 @@ +from typing import Any, Dict, List, Optional, Union, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.emails._attachment import Attachment, RemoteAttachment +from resend.inboxes._query import build_inbox_query_path, without_keys + +from ._inbox_thread_email import InboxThreadEmail + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class InboxThreadEmails: + """ + InboxThreadEmails manages the messages of an inbox thread. + """ + + class ListParams(TypedDict): + """ + ListParams are the pagination parameters for listing the messages of a thread. + Pass the ``id`` of the last message as ``after`` to get the next, newer page. + Pass the ``id`` of the first message as ``before`` to get the previous, older page. + + Attributes: + limit (NotRequired[int]): Number of messages to retrieve. Maximum is 100, minimum is 1. + after (NotRequired[str]): The message ID after which to retrieve more messages. + before (NotRequired[str]): The message ID before which to retrieve more messages. + """ + + limit: NotRequired[int] + """ + Number of messages to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The message ID after which we'll retrieve more messages (for pagination). + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The message ID before which we'll retrieve more messages (for pagination). + Cannot be used with the after parameter. + """ + + class ListResponse(BaseResponse): + """ + ListResponse is a page of the messages of a thread. + + Attributes: + object (str): The object type, always "list" + has_more (bool): Whether there are more results available + data (List[InboxThreadEmail]): The messages on this page, oldest first + """ + + object: str + """ + The object type, always "list". + """ + has_more: bool + """ + Whether there are more results available for pagination. + """ + data: List[InboxThreadEmail] + """ + The messages of the thread on this page, oldest first. + """ + + class ReplyParams(TypedDict): + """ + ReplyParams are the parameters for replying to a thread email. + At least one of ``html`` or ``text`` must contain more than whitespace. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + email_id (str): The Email ID of the message to reply to. + html (NotRequired[str]): The HTML body of the reply. + text (NotRequired[str]): The plain-text body of the reply. + cc (NotRequired[Union[str, List[str]]]): CC recipients. + bcc (NotRequired[Union[str, List[str]]]): BCC recipients. + subject (NotRequired[str]): The subject of the reply. + reply_all (NotRequired[bool]): Reply to everyone on the original message. + attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): Files to attach. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + thread_id: str + """ + The Thread ID. + """ + email_id: str + """ + The Email ID of the message to reply to, as returned in ``data[].id`` + by the list thread emails endpoint. + """ + html: NotRequired[str] + """ + The HTML body of the reply. + """ + text: NotRequired[str] + """ + The plain-text body of the reply. + """ + cc: NotRequired[Union[str, List[str]]] + """ + CC recipient email address. For multiple addresses, send a list of strings. + Without ``reply_all``, the reply copies only these addresses. With + ``reply_all``, these addresses are merged with the copied recipients, + without duplicates. ``cc`` and ``bcc`` combined must be fewer than 50 + addresses. After the ``to`` recipients are added, the reply cannot exceed + 50 recipients. + """ + bcc: NotRequired[Union[str, List[str]]] + """ + BCC recipient email address. For multiple addresses, send a list of strings. + BCC recipients of the original message are never copied. + """ + subject: NotRequired[str] + """ + The subject of the reply. Cannot contain a line break. When omitted, the + thread subject is used, prefixed with ``Re:`` if it does not already start with it. + """ + reply_all: NotRequired[bool] + """ + Reply to everyone on the original message. The reply goes to the author + of the message, or to its ``Reply-To`` addresses if it has any. The other + ``to`` and ``cc`` recipients of the message are copied in ``cc``. The inbox + address is always excluded. For a message the inbox sent, the reply goes + to that message's ``to`` recipients and copies its ``cc`` recipients. + Defaults to False. + """ + attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] + """ + Files to attach, as on ``Emails.send``. Each one needs ``content`` or + ``path``. Up to 10 files. The ``content`` of all files can total at most + 25 MB. A ``path`` file counts toward the 10 files only, because it is + fetched when the email is sent. + """ + + class ReplyResponse(BaseResponse): + """ + ReplyResponse is the response of replying to a thread email. + + Attributes: + id (str): The ID of the sent email + """ + + id: str + """ + The ID of the sent email. Use it with the emails endpoints. + """ + + class ForwardParams(TypedDict): + """ + ForwardParams are the parameters for forwarding a thread email. + + Attributes: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + email_id (str): The Email ID of the message to forward. + to (Union[str, List[str]]): The recipients. + cc (NotRequired[Union[str, List[str]]]): CC recipients. + bcc (NotRequired[Union[str, List[str]]]): BCC recipients. + html (NotRequired[str]): An HTML note sent above the quoted original. + text (NotRequired[str]): A plain-text note sent above the quoted original. + subject (NotRequired[str]): The subject of the forward. + attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): Files to attach. + """ + + inbox_id: str + """ + The Inbox ID. An email address is not accepted. + """ + thread_id: str + """ + The Thread ID. + """ + email_id: str + """ + The Email ID of the message to forward, as returned in ``data[].id`` + by the list thread emails endpoint. + """ + to: Union[str, List[str]] + """ + Recipient email address. For multiple addresses, send a list of strings. + Must name at least one address. ``to``, ``cc`` and ``bcc`` combined cannot + exceed 50 recipients. + """ + cc: NotRequired[Union[str, List[str]]] + """ + CC recipient email address. For multiple addresses, send a list of strings. + Counts toward the 50-recipient limit. + """ + bcc: NotRequired[Union[str, List[str]]] + """ + BCC recipient email address. For multiple addresses, send a list of strings. + Counts toward the 50-recipient limit. + """ + html: NotRequired[str] + """ + An HTML note sent above the quoted original. + """ + text: NotRequired[str] + """ + A plain-text note sent above the quoted original. + """ + subject: NotRequired[str] + """ + The subject of the forward. Cannot contain a line break. When omitted, the + thread subject is used, prefixed with ``Fwd:`` if it does not already start with it. + """ + attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] + """ + Files to attach, as on ``Emails.send``. Each one needs ``content`` or + ``path``. They share one limit with the attachments of the original + message, which the forward also sends. Together they can have up to 10 + files and at most 25 MB of ``content``. A ``path`` file counts toward the + 10 files only, because it is fetched when the email is sent. + """ + + class ForwardResponse(BaseResponse): + """ + ForwardResponse is the response of forwarding a thread email. + + Attributes: + id (str): The ID of the sent email + """ + + id: str + """ + The ID of the sent email. Use it with the emails endpoints. + """ + + @classmethod + def list( + cls, inbox_id: str, thread_id: str, params: Optional[ListParams] = None + ) -> ListResponse: + """ + Retrieve the messages of a thread, oldest first. You cannot combine + ``after`` and ``before``. + see more: https://resend.com/docs/api-reference/inboxes/list-thread-emails + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A page of the messages of the thread + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/threads/{thread_id}/emails", + cast(Dict[Any, Any], params) if params else None, + ) + resp = request.Request[InboxThreadEmails.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def get(cls, inbox_id: str, thread_id: str, email_id: str) -> InboxThreadEmail: + """ + Retrieve a single thread email. + see more: https://resend.com/docs/api-reference/inboxes/get-thread-email + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + email_id (str): The Email ID, as returned in ``data[].id`` by the list + thread emails endpoint. + + Returns: + InboxThreadEmail: The thread email + """ + path = f"/inboxes/{inbox_id}/threads/{thread_id}/emails/{email_id}" + resp = request.Request[InboxThreadEmail]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def reply(cls, params: ReplyParams) -> ReplyResponse: + """ + Send a reply from the inbox address right away, with no stored draft. + The ``to`` recipients come from the message you reply to. For a message + the inbox received, the reply goes to its ``Reply-To`` addresses, or to + its sender when it has no ``Reply-To``. For a message the inbox sent, the + reply goes to that message's ``to`` recipients. Set ``reply_all`` to also + copy the other recipients of the message. The reply threads under the + message through the ``In-Reply-To`` and ``References`` headers. + see more: https://resend.com/docs/api-reference/inboxes/reply-thread-email + + Args: + params (ReplyParams): The path IDs and the content of the reply + + Returns: + ReplyResponse: The ID of the sent email + """ + path = ( + f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"/emails/{params['email_id']}/reply" + ) + resp = request.Request[InboxThreadEmails.ReplyResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id", "email_id"), + verb="post", + ).perform_with_content() + return resp + + @classmethod + def forward(cls, params: ForwardParams) -> ForwardResponse: + """ + Forward a message from the inbox address right away, with no stored draft. + The original message is quoted under a forwarded-message banner, below the + optional ``html`` or ``text`` note. The attachments of the original message + are sent with the forward, together with any ``attachments`` you add. + The request fails when they exceed 10 files or 25 MB of ``content`` in total. + see more: https://resend.com/docs/api-reference/inboxes/forward-thread-email + + Args: + params (ForwardParams): The path IDs, the recipients and the note + + Returns: + ForwardResponse: The ID of the sent email + """ + path = ( + f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"/emails/{params['email_id']}/forward" + ) + resp = request.Request[InboxThreadEmails.ForwardResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id", "email_id"), + verb="post", + ).perform_with_content() + return resp + + @classmethod + async def list_async( + cls, inbox_id: str, thread_id: str, params: Optional[ListParams] = None + ) -> ListResponse: + """ + Retrieve the messages of a thread, oldest first (async). You cannot + combine ``after`` and ``before``. + see more: https://resend.com/docs/api-reference/inboxes/list-thread-emails + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A page of the messages of the thread + """ + path = build_inbox_query_path( + f"/inboxes/{inbox_id}/threads/{thread_id}/emails", + cast(Dict[Any, Any], params) if params else None, + ) + resp = await AsyncRequest[InboxThreadEmails.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def get_async( + cls, inbox_id: str, thread_id: str, email_id: str + ) -> InboxThreadEmail: + """ + Retrieve a single thread email (async). + see more: https://resend.com/docs/api-reference/inboxes/get-thread-email + + Args: + inbox_id (str): The Inbox ID. An email address is not accepted. + thread_id (str): The Thread ID. + email_id (str): The Email ID, as returned in ``data[].id`` by the list + thread emails endpoint. + + Returns: + InboxThreadEmail: The thread email + """ + path = f"/inboxes/{inbox_id}/threads/{thread_id}/emails/{email_id}" + resp = await AsyncRequest[InboxThreadEmail]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def reply_async(cls, params: ReplyParams) -> ReplyResponse: + """ + Send a reply from the inbox address right away, with no stored draft (async). + The ``to`` recipients come from the message you reply to. For a message + the inbox received, the reply goes to its ``Reply-To`` addresses, or to + its sender when it has no ``Reply-To``. For a message the inbox sent, the + reply goes to that message's ``to`` recipients. Set ``reply_all`` to also + copy the other recipients of the message. The reply threads under the + message through the ``In-Reply-To`` and ``References`` headers. + see more: https://resend.com/docs/api-reference/inboxes/reply-thread-email + + Args: + params (ReplyParams): The path IDs and the content of the reply + + Returns: + ReplyResponse: The ID of the sent email + """ + path = ( + f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"/emails/{params['email_id']}/reply" + ) + resp = await AsyncRequest[InboxThreadEmails.ReplyResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id", "email_id"), + verb="post", + ).perform_with_content() + return resp + + @classmethod + async def forward_async(cls, params: ForwardParams) -> ForwardResponse: + """ + Forward a message from the inbox address right away, with no stored + draft (async). The original message is quoted under a forwarded-message + banner, below the optional ``html`` or ``text`` note. The attachments of + the original message are sent with the forward, together with any + ``attachments`` you add. The request fails when they exceed 10 files or + 25 MB of ``content`` in total. + see more: https://resend.com/docs/api-reference/inboxes/forward-thread-email + + Args: + params (ForwardParams): The path IDs, the recipients and the note + + Returns: + ForwardResponse: The ID of the sent email + """ + path = ( + f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"/emails/{params['email_id']}/forward" + ) + resp = await AsyncRequest[InboxThreadEmails.ForwardResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id", "email_id"), + verb="post", + ).perform_with_content() + return resp From fe2ede9c5d8ec075893306b3867ce956e6805224 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:09:48 -0300 Subject: [PATCH 02/20] test(inboxes): cover every Inboxes method Add sync and async tests for each method: the happy path and the no-content error. A request-level test checks the URL, the verb, the query string and the JSON body of every route. --- tests/inboxes_async_test.py | 450 +++++++++++++++++++ tests/inboxes_test.py | 862 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1312 insertions(+) create mode 100644 tests/inboxes_async_test.py create mode 100644 tests/inboxes_test.py diff --git a/tests/inboxes_async_test.py b/tests/inboxes_async_test.py new file mode 100644 index 0000000..5962965 --- /dev/null +++ b/tests/inboxes_async_test.py @@ -0,0 +1,450 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest +from tests.inboxes_test import (DRAFT, DRAFT_ID, EMAIL_ID, INBOX, INBOX_ID, + LABEL_ID, SENT_EMAIL_ID, THREAD, THREAD_EMAIL, + THREAD_ID, THREAD_SUMMARY) + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendInboxesAsync(AsyncResendBaseTest): + async def test_inboxes_create_async(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID}) + inbox: resend.Inboxes.CreateResponse = await resend.Inboxes.create_async( + {"email_address": "support@acme.com"} + ) + assert inbox["id"] == INBOX_ID + + async def test_should_create_inboxes_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.create_async({"email_address": "support@acme.com"}) + + async def test_inboxes_list_async(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": []}) + inboxes: resend.Inboxes.ListResponse = await resend.Inboxes.list_async() + assert inboxes["object"] == "list" + assert inboxes["data"] == [] + + async def test_should_list_inboxes_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.list_async() + + async def test_inboxes_get_async(self) -> None: + self.set_mock_json(INBOX) + inbox: resend.Inbox = await resend.Inboxes.get_async("support@acme.com") + assert inbox["email_address"] == "support@acme.com" + + async def test_should_get_inboxes_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.get_async(INBOX_ID) + + async def test_inboxes_update_async(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID}) + inbox: resend.Inboxes.UpdateResponse = await resend.Inboxes.update_async( + {"id": INBOX_ID, "name": "Support"} + ) + assert inbox["id"] == INBOX_ID + + async def test_should_update_inboxes_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.update_async({"id": INBOX_ID, "name": "Support"}) + + async def test_inboxes_remove_async(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID, "deleted": True}) + removed: resend.Inboxes.RemoveResponse = await resend.Inboxes.remove_async( + INBOX_ID + ) + assert removed["deleted"] is True + + async def test_should_remove_inboxes_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.remove_async(INBOX_ID) + + +class TestResendInboxAgentAsync(AsyncResendBaseTest): + async def test_inbox_agent_get_async(self) -> None: + self.set_mock_json( + { + "object": "inbox_agent", + "instructions": "Escalate legal threats.", + "tone": None, + "enabled_actions": ["draft_reply"], + } + ) + agent: resend.InboxAgentSettings = await resend.Inboxes.Agent.get_async( + INBOX_ID + ) + assert agent["enabled_actions"] == ["draft_reply"] + + async def test_should_get_inbox_agent_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Agent.get_async(INBOX_ID) + + async def test_inbox_agent_update_async(self) -> None: + self.set_mock_json( + {"object": "inbox_agent", "id": "a1f84a4e-6f2b-4f0a-9c1d-8a2e5b3c7d90"} + ) + agent: resend.Inboxes.Agent.UpdateResponse = ( + await resend.Inboxes.Agent.update_async( + {"inbox_id": INBOX_ID, "enabled_actions": []} + ) + ) + assert agent["object"] == "inbox_agent" + + async def test_should_update_inbox_agent_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Agent.update_async( + {"inbox_id": INBOX_ID, "tone": None} + ) + + +class TestResendInboxLabelsAsync(AsyncResendBaseTest): + async def test_inbox_labels_list_async(self) -> None: + self.set_mock_json({"object": "list", "data": []}) + labels: resend.Inboxes.Labels.ListResponse = ( + await resend.Inboxes.Labels.list_async(INBOX_ID) + ) + assert labels["data"] == [] + + async def test_should_list_inbox_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Labels.list_async(INBOX_ID) + + async def test_inbox_labels_create_async(self) -> None: + self.set_mock_json({"object": "inbox_label", "id": LABEL_ID}) + label: resend.Inboxes.Labels.CreateResponse = ( + await resend.Inboxes.Labels.create_async( + {"inbox_id": INBOX_ID, "name": "Urgent"} + ) + ) + assert label["id"] == LABEL_ID + + async def test_should_create_inbox_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Labels.create_async( + {"inbox_id": INBOX_ID, "name": "Urgent"} + ) + + async def test_inbox_labels_update_async(self) -> None: + self.set_mock_json({"object": "inbox_label", "id": LABEL_ID}) + label: resend.Inboxes.Labels.UpdateResponse = ( + await resend.Inboxes.Labels.update_async( + {"inbox_id": INBOX_ID, "id": LABEL_ID, "color": "#F76B15"} + ) + ) + assert label["id"] == LABEL_ID + + async def test_should_update_inbox_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Labels.update_async( + {"inbox_id": INBOX_ID, "id": LABEL_ID, "name": "Billing"} + ) + + async def test_inbox_labels_remove_async(self) -> None: + self.set_mock_json({"object": "inbox_label", "id": LABEL_ID, "deleted": True}) + removed: resend.Inboxes.Labels.RemoveResponse = ( + await resend.Inboxes.Labels.remove_async(INBOX_ID, LABEL_ID) + ) + assert removed["deleted"] is True + + async def test_should_remove_inbox_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Labels.remove_async(INBOX_ID, LABEL_ID) + + +class TestResendInboxThreadsAsync(AsyncResendBaseTest): + async def test_inbox_threads_list_async(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": [THREAD]}) + threads: resend.Inboxes.Threads.ListResponse = ( + await resend.Inboxes.Threads.list_async(INBOX_ID, {"labels": [LABEL_ID]}) + ) + assert threads["data"][0]["id"] == THREAD_ID + + async def test_should_list_inbox_threads_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.list_async(INBOX_ID) + + async def test_inbox_threads_search_async(self) -> None: + result = dict(THREAD) + result["matched_email_id"] = None + result["highlights"] = {} + self.set_mock_json({"object": "list", "has_more": False, "data": [result]}) + threads: resend.Inboxes.Threads.SearchResponse = ( + await resend.Inboxes.Threads.search_async(INBOX_ID, {"query": "refund"}) + ) + assert threads["data"][0]["matched_email_id"] is None + assert threads["data"][0]["highlights"] == {} + + async def test_should_search_inbox_threads_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.search_async(INBOX_ID) + + async def test_inbox_threads_get_async(self) -> None: + self.set_mock_json(THREAD_SUMMARY) + thread: resend.InboxThreadSummary = await resend.Inboxes.Threads.get_async( + INBOX_ID, THREAD_ID + ) + assert thread["object"] == "inbox_thread" + + async def test_should_get_inbox_threads_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.get_async(INBOX_ID, THREAD_ID) + + async def test_inbox_threads_update_async(self) -> None: + self.set_mock_json(THREAD_SUMMARY) + thread: resend.InboxThreadSummary = await resend.Inboxes.Threads.update_async( + {"inbox_id": INBOX_ID, "id": THREAD_ID, "remove_label_ids": [LABEL_ID]} + ) + assert thread["id"] == THREAD_ID + + async def test_should_update_inbox_threads_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.update_async( + {"inbox_id": INBOX_ID, "id": THREAD_ID, "read": True} + ) + + async def test_inbox_threads_remove_async(self) -> None: + self.set_mock_json( + {"object": "inbox_thread", "id": THREAD_ID, "deleted": False} + ) + removed: resend.Inboxes.Threads.RemoveResponse = ( + await resend.Inboxes.Threads.remove_async(INBOX_ID, THREAD_ID) + ) + assert removed["deleted"] is False + + async def test_should_remove_inbox_threads_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.remove_async(INBOX_ID, THREAD_ID) + + +class TestResendInboxThreadEmailsAsync(AsyncResendBaseTest): + async def test_inbox_thread_emails_list_async(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": [THREAD_EMAIL]}) + emails: resend.Inboxes.Threads.Emails.ListResponse = ( + await resend.Inboxes.Threads.Emails.list_async(INBOX_ID, THREAD_ID) + ) + assert emails["data"][0]["id"] == EMAIL_ID + + async def test_should_list_inbox_thread_emails_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Emails.list_async(INBOX_ID, THREAD_ID) + + async def test_inbox_thread_emails_get_async(self) -> None: + self.set_mock_json(THREAD_EMAIL) + email: resend.InboxThreadEmail = await resend.Inboxes.Threads.Emails.get_async( + INBOX_ID, THREAD_ID, EMAIL_ID + ) + assert email["id"] == EMAIL_ID + + async def test_should_get_inbox_thread_emails_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Emails.get_async( + INBOX_ID, THREAD_ID, EMAIL_ID + ) + + async def test_inbox_thread_emails_reply_async(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + reply: resend.Inboxes.Threads.Emails.ReplyResponse = ( + await resend.Inboxes.Threads.Emails.reply_async( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "text": "Refund issued for order 1041.", + } + ) + ) + assert reply["id"] == SENT_EMAIL_ID + + async def test_should_reply_inbox_thread_emails_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Emails.reply_async( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "html": "

Hi

", + } + ) + + async def test_inbox_thread_emails_forward_async(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + forward: resend.Inboxes.Threads.Emails.ForwardResponse = ( + await resend.Inboxes.Threads.Emails.forward_async( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": ["colleague@example.org"], + } + ) + ) + assert forward["id"] == SENT_EMAIL_ID + + async def test_should_forward_inbox_thread_emails_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Emails.forward_async( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": "colleague@example.org", + } + ) + + +class TestResendInboxDraftsAsync(AsyncResendBaseTest): + async def test_inbox_drafts_list_async(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": []}) + drafts: resend.Inboxes.Drafts.ListResponse = ( + await resend.Inboxes.Drafts.list_async(INBOX_ID) + ) + assert drafts["data"] == [] + + async def test_should_list_inbox_drafts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Drafts.list_async(INBOX_ID) + + async def test_inbox_drafts_create_async(self) -> None: + self.set_mock_json({"object": "inbox_draft", "id": DRAFT_ID}) + draft: resend.Inboxes.Drafts.CreateResponse = ( + await resend.Inboxes.Drafts.create_async( + {"inbox_id": INBOX_ID, "to": "ada@example.org", "text": "Hi"} + ) + ) + assert draft["id"] == DRAFT_ID + + async def test_should_create_inbox_drafts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Drafts.create_async( + {"inbox_id": INBOX_ID, "text": "Hi"} + ) + + async def test_inbox_drafts_get_async(self) -> None: + self.set_mock_json(DRAFT) + draft: resend.InboxDraft = await resend.Inboxes.Drafts.get_async( + INBOX_ID, DRAFT_ID + ) + assert draft["id"] == DRAFT_ID + + async def test_should_get_inbox_drafts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Drafts.get_async(INBOX_ID, DRAFT_ID) + + async def test_inbox_drafts_update_async(self) -> None: + self.set_mock_json(DRAFT) + draft: resend.InboxDraft = await resend.Inboxes.Drafts.update_async( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "subject": "Refund for order 1041"} + ) + assert draft["subject"] == "Refund for order 1041" + + async def test_should_update_inbox_drafts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Drafts.update_async( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "text": None} + ) + + async def test_inbox_drafts_remove_async(self) -> None: + self.set_mock_json({"object": "inbox_draft", "id": DRAFT_ID, "deleted": True}) + removed: resend.Inboxes.Drafts.RemoveResponse = ( + await resend.Inboxes.Drafts.remove_async(INBOX_ID, DRAFT_ID) + ) + assert removed["deleted"] is True + + async def test_should_remove_inbox_drafts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Drafts.remove_async(INBOX_ID, DRAFT_ID) + + async def test_inbox_drafts_send_async(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + sent: resend.Inboxes.Drafts.SendResponse = ( + await resend.Inboxes.Drafts.send_async(INBOX_ID, DRAFT_ID) + ) + assert sent["id"] == SENT_EMAIL_ID + + async def test_should_send_inbox_drafts_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Drafts.send_async(INBOX_ID, DRAFT_ID) diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py new file mode 100644 index 0000000..edf350f --- /dev/null +++ b/tests/inboxes_test.py @@ -0,0 +1,862 @@ +from typing import Any, Dict +from unittest import TestCase +from unittest.mock import create_autospec + +import resend +from resend import HTTPClient +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + +INBOX_ID = "b3e2b2b6-3f0e-4c8e-9ad3-2f43a1e2c7f1" +LABEL_ID = "7f9c1d2e-4a6b-4c3d-8e15-9b0a7c6d5e34" +THREAD_ID = "4d8e2a1c-9b3f-4c6d-8a21-3e5f7c9c0d12" +EMAIL_ID = "5b1a9f47-2c8d-4e6f-9a03-1d2e3f4a5b60" +DRAFT_ID = "c3a1e8b4-2d5f-4a7c-9e10-6b8d7f5a4c32" +SENT_EMAIL_ID = "6a0c8e58-3d9e-4f7a-8b14-2e3f4a5b6c71" + +INBOX: Dict[str, Any] = { + "object": "inbox", + "id": INBOX_ID, + "name": "Customer Support", + "email_address": "support@acme.com", + "domain_id": "d91cd9bd-1176-453e-8fc1-35364d380206", + "receiving_address": None, + "from_name": "Ada from Support", + "unread": 3, + "drafts": 2, + "last_received": "2026-08-05T14:03:11.229Z", + "created_at": "2026-08-01T09:12:44.512Z", +} + +THREAD_LABEL: Dict[str, Any] = {"id": LABEL_ID, "name": "Urgent", "color": "#E93D82"} + +THREAD: Dict[str, Any] = { + "id": THREAD_ID, + "subject": "Refund for order 1041", + "from": "ada@example.org", + "to": ["support@acme.com"], + "cc": [], + "bcc": [], + "labels": [THREAD_LABEL], + "message_count": 2, + "has_attachment": True, + "has_draft": False, + "read": False, + "received_at": "2026-08-05T14:03:11.229Z", + "folder": "inbox", +} + +THREAD_SUMMARY: Dict[str, Any] = { + "object": "inbox_thread", + "id": THREAD_ID, + "subject": "Refund for order 1041", + "folder": "inbox", + "labels": [THREAD_LABEL], + "read": False, +} + +THREAD_EMAIL: Dict[str, Any] = { + "id": EMAIL_ID, + "direction": "inbound", + "from": "Ada Lovelace ", + "to": ["support@acme.com"], + "cc": [], + "bcc": [], + "reply_to": ["replies@example.org"], + "subject": "Refund for order 1041", + "message_id": "<1041@example.org>", + "html": "

Could I get a refund for order 1041?

", + "text": "Could I get a refund for order 1041?", + "attachments": [ + { + "id": "9d4f2b81-6c3a-4e7d-8b12-0a5c6d7e8f90", + "filename": "receipt.pdf", + "size": 20841, + } + ], + "read": False, + "received_at": "2026-08-05T14:03:11.229Z", +} + +DRAFT: Dict[str, Any] = { + "object": "inbox_draft", + "id": DRAFT_ID, + "type": "standalone", + "to": ["ada@example.org"], + "cc": [], + "bcc": [], + "subject": "Refund for order 1041", + "html": "

Refund issued for order 1041.

", + "text": "Refund issued for order 1041.", + "thread_id": None, + "reply_to_email_id": None, + "email_id": None, + "created_at": "2026-08-05T14:12:04.110Z", + "updated_at": "2026-08-05T14:12:04.110Z", +} + + +class TestResendInboxes(ResendBaseTest): + def test_inboxes_create(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID}) + params: resend.Inboxes.CreateParams = { + "email_address": "support@acme.com", + "name": "Customer Support", + "from_name": "Ada from Support", + "forwarding": False, + } + inbox: resend.Inboxes.CreateResponse = resend.Inboxes.create(params) + assert inbox["object"] == "inbox" + assert inbox["id"] == INBOX_ID + + def test_should_create_inboxes_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.create({"email_address": "support@acme.com"}) + + def test_inboxes_list(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": INBOX_ID, + "name": "Customer Support", + "email_address": "support@acme.com", + "from_name": None, + "unread": 3, + "last_received": None, + } + ], + } + ) + inboxes: resend.Inboxes.ListResponse = resend.Inboxes.list({"limit": 10}) + assert inboxes["object"] == "list" + assert inboxes["has_more"] is False + assert inboxes["data"][0]["id"] == INBOX_ID + assert inboxes["data"][0]["email_address"] == "support@acme.com" + assert inboxes["data"][0]["from_name"] is None + + def test_should_list_inboxes_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.list() + + def test_inboxes_get(self) -> None: + self.set_mock_json(INBOX) + inbox: resend.Inbox = resend.Inboxes.get(INBOX_ID) + assert inbox["object"] == "inbox" + assert inbox["id"] == INBOX_ID + assert inbox["receiving_address"] is None + assert inbox["unread"] == 3 + assert inbox["drafts"] == 2 + + def test_should_get_inboxes_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.get(INBOX_ID) + + def test_inboxes_update(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID}) + params: resend.Inboxes.UpdateParams = {"id": INBOX_ID, "name": "Support"} + inbox: resend.Inboxes.UpdateResponse = resend.Inboxes.update(params) + assert inbox["object"] == "inbox" + assert inbox["id"] == INBOX_ID + + def test_should_update_inboxes_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.update({"id": INBOX_ID, "name": "Support"}) + + def test_inboxes_remove(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID, "deleted": True}) + removed: resend.Inboxes.RemoveResponse = resend.Inboxes.remove(INBOX_ID) + assert removed["id"] == INBOX_ID + assert removed["deleted"] is True + + def test_should_remove_inboxes_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.remove(INBOX_ID) + + +class TestResendInboxAgent(ResendBaseTest): + def test_inbox_agent_get(self) -> None: + self.set_mock_json( + { + "object": "inbox_agent", + "instructions": None, + "tone": None, + "enabled_actions": [], + } + ) + agent: resend.InboxAgentSettings = resend.Inboxes.Agent.get(INBOX_ID) + assert agent["object"] == "inbox_agent" + assert agent["instructions"] is None + assert agent["enabled_actions"] == [] + + def test_should_get_inbox_agent_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Agent.get(INBOX_ID) + + def test_inbox_agent_update(self) -> None: + self.set_mock_json( + {"object": "inbox_agent", "id": "a1f84a4e-6f2b-4f0a-9c1d-8a2e5b3c7d90"} + ) + params: resend.Inboxes.Agent.UpdateParams = { + "inbox_id": INBOX_ID, + "tone": "friendly and concise", + "enabled_actions": ["draft_reply", "add_labels"], + } + agent: resend.Inboxes.Agent.UpdateResponse = resend.Inboxes.Agent.update( + params + ) + assert agent["object"] == "inbox_agent" + assert agent["id"] == "a1f84a4e-6f2b-4f0a-9c1d-8a2e5b3c7d90" + + def test_should_update_inbox_agent_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Agent.update({"inbox_id": INBOX_ID, "tone": None}) + + +class TestResendInboxLabels(ResendBaseTest): + def test_inbox_labels_list(self) -> None: + self.set_mock_json( + { + "object": "list", + "data": [ + { + "id": LABEL_ID, + "name": "Urgent", + "color": "#E93D82", + "created_at": "2026-08-05T14:07:42.881Z", + } + ], + } + ) + labels: resend.Inboxes.Labels.ListResponse = resend.Inboxes.Labels.list( + INBOX_ID + ) + assert labels["object"] == "list" + assert labels["data"][0]["id"] == LABEL_ID + assert labels["data"][0]["color"] == "#E93D82" + + def test_should_list_inbox_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Labels.list(INBOX_ID) + + def test_inbox_labels_create(self) -> None: + self.set_mock_json({"object": "inbox_label", "id": LABEL_ID}) + params: resend.Inboxes.Labels.CreateParams = { + "inbox_id": INBOX_ID, + "name": "Urgent", + "color": "#E93D82", + } + label: resend.Inboxes.Labels.CreateResponse = resend.Inboxes.Labels.create( + params + ) + assert label["object"] == "inbox_label" + assert label["id"] == LABEL_ID + + def test_should_create_inbox_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Labels.create({"inbox_id": INBOX_ID, "name": "Urgent"}) + + def test_inbox_labels_update(self) -> None: + self.set_mock_json({"object": "inbox_label", "id": LABEL_ID}) + params: resend.Inboxes.Labels.UpdateParams = { + "inbox_id": INBOX_ID, + "id": LABEL_ID, + "name": "Billing", + "color": "#F76B15", + } + label: resend.Inboxes.Labels.UpdateResponse = resend.Inboxes.Labels.update( + params + ) + assert label["object"] == "inbox_label" + assert label["id"] == LABEL_ID + + def test_should_update_inbox_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Labels.update( + {"inbox_id": INBOX_ID, "id": LABEL_ID, "name": "Billing"} + ) + + def test_inbox_labels_remove(self) -> None: + self.set_mock_json({"object": "inbox_label", "id": LABEL_ID, "deleted": True}) + removed: resend.Inboxes.Labels.RemoveResponse = resend.Inboxes.Labels.remove( + INBOX_ID, LABEL_ID + ) + assert removed["id"] == LABEL_ID + assert removed["deleted"] is True + + def test_should_remove_inbox_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Labels.remove(INBOX_ID, LABEL_ID) + + +class TestResendInboxThreads(ResendBaseTest): + def test_inbox_threads_list(self) -> None: + self.set_mock_json({"object": "list", "has_more": True, "data": [THREAD]}) + params: resend.Inboxes.Threads.ListParams = { + "folders": ["inbox", "sent"], + "read": False, + "limit": 10, + } + threads: resend.Inboxes.Threads.ListResponse = resend.Inboxes.Threads.list( + INBOX_ID, params + ) + assert threads["object"] == "list" + assert threads["has_more"] is True + thread = threads["data"][0] + assert thread["id"] == THREAD_ID + assert thread["from"] == "ada@example.org" + assert thread["labels"][0]["color"] == "#E93D82" + assert thread["folder"] == "inbox" + + def test_should_list_inbox_threads_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.list(INBOX_ID) + + def test_inbox_threads_search(self) -> None: + result = dict(THREAD) + result["matched_email_id"] = "e8a3f1c6-2b9d-4e5a-a7c4-9d1b6f3e2a80" + result["highlights"] = {"subject": ["September **invoice**"]} + self.set_mock_json({"object": "list", "has_more": False, "data": [result]}) + params: resend.Inboxes.Threads.SearchParams = { + "query": "invoice -draft", + "has_attachment": True, + } + threads: resend.Inboxes.Threads.SearchResponse = ( + resend.Inboxes.Threads.search(INBOX_ID, params) + ) + assert threads["object"] == "list" + match = threads["data"][0] + assert match["matched_email_id"] == "e8a3f1c6-2b9d-4e5a-a7c4-9d1b6f3e2a80" + assert match["highlights"]["subject"] == ["September **invoice**"] + + def test_should_search_inbox_threads_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.search(INBOX_ID) + + def test_inbox_threads_get(self) -> None: + self.set_mock_json(THREAD_SUMMARY) + thread: resend.InboxThreadSummary = resend.Inboxes.Threads.get( + INBOX_ID, THREAD_ID + ) + assert thread["object"] == "inbox_thread" + assert thread["id"] == THREAD_ID + assert thread["read"] is False + + def test_should_get_inbox_threads_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.get(INBOX_ID, THREAD_ID) + + def test_inbox_threads_update(self) -> None: + summary = dict(THREAD_SUMMARY) + summary["folder"] = "archive" + summary["read"] = True + self.set_mock_json(summary) + params: resend.Inboxes.Threads.UpdateParams = { + "inbox_id": INBOX_ID, + "id": THREAD_ID, + "read": True, + "folder": "archive", + "add_label_ids": [LABEL_ID], + } + thread: resend.InboxThreadSummary = resend.Inboxes.Threads.update(params) + assert thread["folder"] == "archive" + assert thread["read"] is True + + def test_should_update_inbox_threads_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.update( + {"inbox_id": INBOX_ID, "id": THREAD_ID, "read": True} + ) + + def test_inbox_threads_remove(self) -> None: + self.set_mock_json({"object": "inbox_thread", "id": THREAD_ID, "deleted": True}) + removed: resend.Inboxes.Threads.RemoveResponse = ( + resend.Inboxes.Threads.remove(INBOX_ID, THREAD_ID) + ) + assert removed["id"] == THREAD_ID + assert removed["deleted"] is True + + def test_should_remove_inbox_threads_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.remove(INBOX_ID, THREAD_ID) + + +class TestResendInboxThreadEmails(ResendBaseTest): + def test_inbox_thread_emails_list(self) -> None: + self.set_mock_json({"object": "list", "has_more": False, "data": [THREAD_EMAIL]}) + emails: resend.Inboxes.Threads.Emails.ListResponse = ( + resend.Inboxes.Threads.Emails.list(INBOX_ID, THREAD_ID, {"limit": 5}) + ) + assert emails["object"] == "list" + email = emails["data"][0] + assert email["id"] == EMAIL_ID + assert email["from"] == "Ada Lovelace " + assert email["attachments"][0]["filename"] == "receipt.pdf" + + def test_should_list_inbox_thread_emails_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Emails.list(INBOX_ID, THREAD_ID) + + def test_inbox_thread_emails_get(self) -> None: + self.set_mock_json(THREAD_EMAIL) + email: resend.InboxThreadEmail = resend.Inboxes.Threads.Emails.get( + INBOX_ID, THREAD_ID, EMAIL_ID + ) + assert email["id"] == EMAIL_ID + assert email["direction"] == "inbound" + assert email["reply_to"] == ["replies@example.org"] + + def test_should_get_inbox_thread_emails_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Emails.get(INBOX_ID, THREAD_ID, EMAIL_ID) + + def test_inbox_thread_emails_reply(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + params: resend.Inboxes.Threads.Emails.ReplyParams = { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "text": "Refund issued for order 1041.", + "reply_all": True, + "attachments": [{"filename": "note.txt", "content": "aGVsbG8="}], + } + reply: resend.Inboxes.Threads.Emails.ReplyResponse = ( + resend.Inboxes.Threads.Emails.reply(params) + ) + assert reply["id"] == SENT_EMAIL_ID + + def test_should_reply_inbox_thread_emails_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Emails.reply( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "text": "Hi", + } + ) + + def test_inbox_thread_emails_forward(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + params: resend.Inboxes.Threads.Emails.ForwardParams = { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": ["colleague@example.org"], + "text": "Flagging this refund request for you.", + } + forward: resend.Inboxes.Threads.Emails.ForwardResponse = ( + resend.Inboxes.Threads.Emails.forward(params) + ) + assert forward["id"] == SENT_EMAIL_ID + + def test_should_forward_inbox_thread_emails_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Emails.forward( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": "colleague@example.org", + } + ) + + +class TestResendInboxDrafts(ResendBaseTest): + def test_inbox_drafts_list(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": DRAFT_ID, + "type": "standalone", + "to": ["ada@example.org"], + "cc": [], + "bcc": [], + "subject": "Refund for order 1041", + "snippet": "Refund issued for order 1041.", + "thread_id": None, + "reply_to_email_id": None, + "updated_at": "2026-08-05T14:12:04.110Z", + } + ], + } + ) + drafts: resend.Inboxes.Drafts.ListResponse = resend.Inboxes.Drafts.list( + INBOX_ID + ) + assert drafts["object"] == "list" + assert drafts["data"][0]["id"] == DRAFT_ID + assert drafts["data"][0]["snippet"] == "Refund issued for order 1041." + + def test_should_list_inbox_drafts_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Drafts.list(INBOX_ID) + + def test_inbox_drafts_create(self) -> None: + self.set_mock_json({"object": "inbox_draft", "id": DRAFT_ID}) + params: resend.Inboxes.Drafts.CreateParams = { + "inbox_id": INBOX_ID, + "to": ["ada@example.org"], + "subject": "Refund for order 1041", + "text": "Refund issued for order 1041.", + } + draft: resend.Inboxes.Drafts.CreateResponse = resend.Inboxes.Drafts.create( + params + ) + assert draft["object"] == "inbox_draft" + assert draft["id"] == DRAFT_ID + + def test_should_create_inbox_drafts_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Drafts.create({"inbox_id": INBOX_ID, "text": "Hi"}) + + def test_inbox_drafts_get(self) -> None: + self.set_mock_json(DRAFT) + draft: resend.InboxDraft = resend.Inboxes.Drafts.get(INBOX_ID, DRAFT_ID) + assert draft["object"] == "inbox_draft" + assert draft["id"] == DRAFT_ID + assert draft["type"] == "standalone" + assert draft["email_id"] is None + + def test_should_get_inbox_drafts_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Drafts.get(INBOX_ID, DRAFT_ID) + + def test_inbox_drafts_update(self) -> None: + draft_json = dict(DRAFT) + draft_json["html"] = None + self.set_mock_json(draft_json) + params: resend.Inboxes.Drafts.UpdateParams = { + "inbox_id": INBOX_ID, + "id": DRAFT_ID, + "text": "Refund issued for order 1041.", + "cc": None, + } + draft: resend.InboxDraft = resend.Inboxes.Drafts.update(params) + assert draft["id"] == DRAFT_ID + assert draft["html"] is None + + def test_should_update_inbox_drafts_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Drafts.update( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "subject": None} + ) + + def test_inbox_drafts_remove(self) -> None: + self.set_mock_json({"object": "inbox_draft", "id": DRAFT_ID, "deleted": True}) + removed: resend.Inboxes.Drafts.RemoveResponse = resend.Inboxes.Drafts.remove( + INBOX_ID, DRAFT_ID + ) + assert removed["id"] == DRAFT_ID + assert removed["deleted"] is True + + def test_should_remove_inbox_drafts_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Drafts.remove(INBOX_ID, DRAFT_ID) + + def test_inbox_drafts_send(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + sent: resend.Inboxes.Drafts.SendResponse = resend.Inboxes.Drafts.send( + INBOX_ID, DRAFT_ID + ) + assert sent["id"] == SENT_EMAIL_ID + + def test_should_send_inbox_drafts_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) + + +class TestInboxesRequest(TestCase): + def setUp(self) -> None: + resend.api_key = "re_123" + self.mock_client = create_autospec(HTTPClient, instance=True) + self.mock_client.name = "mock" + self.mock_client.request.return_value = ( + b'{"object": "inbox", "id": "b3e2b2b6-3f0e-4c8e-9ad3-2f43a1e2c7f1"}', + 200, + {"Content-Type": "application/json"}, + ) + self.previous_http_client = resend.default_http_client + resend.default_http_client = self.mock_client + + def tearDown(self) -> None: + resend.default_http_client = self.previous_http_client + + def last_request(self) -> Dict[str, Any]: + _, kwargs = self.mock_client.request.call_args + return dict(kwargs) + + def test_create_posts_the_params(self) -> None: + resend.Inboxes.create({"email_address": "support@acme.com", "forwarding": True}) + req = self.last_request() + assert req["method"] == "post" + assert req["url"] == "https://api.resend.com/inboxes" + assert req["json"] == {"email_address": "support@acme.com", "forwarding": True} + + def test_list_adds_the_pagination_query(self) -> None: + resend.Inboxes.list({"limit": 2, "after": INBOX_ID}) + req = self.last_request() + assert req["method"] == "get" + assert req["url"] == f"https://api.resend.com/inboxes?limit=2&after={INBOX_ID}" + + def test_get_encodes_an_email_address(self) -> None: + resend.Inboxes.get("support@acme.com") + assert ( + self.last_request()["url"] + == "https://api.resend.com/inboxes/support%40acme.com" + ) + + def test_update_keeps_the_id_out_of_the_body(self) -> None: + resend.Inboxes.update({"id": INBOX_ID, "from_name": ""}) + req = self.last_request() + assert req["method"] == "patch" + assert req["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}" + assert req["json"] == {"from_name": ""} + + def test_remove_deletes_the_inbox(self) -> None: + resend.Inboxes.remove(INBOX_ID) + req = self.last_request() + assert req["method"] == "delete" + assert req["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}" + + def test_agent_get_and_update(self) -> None: + resend.Inboxes.Agent.get(INBOX_ID) + assert self.last_request()["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}/agent" + + resend.Inboxes.Agent.update( + {"inbox_id": INBOX_ID, "instructions": None, "enabled_actions": []} + ) + req = self.last_request() + assert req["method"] == "patch" + assert req["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}/agent" + assert req["json"] == {"instructions": None, "enabled_actions": []} + + def test_labels_paths_and_bodies(self) -> None: + base = f"https://api.resend.com/inboxes/{INBOX_ID}/labels" + resend.Inboxes.Labels.list(INBOX_ID) + assert self.last_request()["url"] == base + + resend.Inboxes.Labels.create({"inbox_id": INBOX_ID, "name": "Urgent"}) + req = self.last_request() + assert (req["method"], req["url"], req["json"]) == ( + "post", + base, + {"name": "Urgent"}, + ) + + resend.Inboxes.Labels.update( + {"inbox_id": INBOX_ID, "id": LABEL_ID, "color": "#F76B15"} + ) + req = self.last_request() + assert (req["method"], req["url"], req["json"]) == ( + "patch", + f"{base}/{LABEL_ID}", + {"color": "#F76B15"}, + ) + + resend.Inboxes.Labels.remove(INBOX_ID, LABEL_ID) + req = self.last_request() + assert (req["method"], req["url"]) == ("delete", f"{base}/{LABEL_ID}") + + def test_threads_list_joins_lists_and_lowercases_booleans(self) -> None: + resend.Inboxes.Threads.list( + INBOX_ID, + {"folders": ["inbox", "sent"], "labels": [LABEL_ID], "read": False}, + ) + assert self.last_request()["url"] == ( + f"https://api.resend.com/inboxes/{INBOX_ID}/threads" + f"?folders=inbox%2Csent&labels={LABEL_ID}&read=false" + ) + + def test_threads_search_builds_the_query(self) -> None: + params: resend.Inboxes.Threads.SearchParams = { + "query": "invoice -draft", + "from": ["isabella@example.com", "carolina@example.com"], + "has_attachment": True, + "start_date": "2026-09-01", + } + resend.Inboxes.Threads.search(INBOX_ID, params) + assert self.last_request()["url"] == ( + f"https://api.resend.com/inboxes/{INBOX_ID}/threads/search" + "?query=invoice+-draft" + "&from=isabella%40example.com%2Ccarolina%40example.com" + "&has_attachment=true&start_date=2026-09-01" + ) + + def test_threads_get_update_and_remove(self) -> None: + url = f"https://api.resend.com/inboxes/{INBOX_ID}/threads/{THREAD_ID}" + resend.Inboxes.Threads.get(INBOX_ID, THREAD_ID) + assert self.last_request()["url"] == url + + resend.Inboxes.Threads.update( + { + "inbox_id": INBOX_ID, + "id": THREAD_ID, + "folder": "archive", + "add_label_ids": [LABEL_ID], + "remove_label_ids": ["2b6d0f4e-8a1c-4e3b-9d57-1c0e8a6f4b21"], + } + ) + req = self.last_request() + assert (req["method"], req["url"]) == ("patch", url) + assert req["json"] == { + "folder": "archive", + "add_label_ids": [LABEL_ID], + "remove_label_ids": ["2b6d0f4e-8a1c-4e3b-9d57-1c0e8a6f4b21"], + } + + resend.Inboxes.Threads.remove(INBOX_ID, THREAD_ID) + req = self.last_request() + assert (req["method"], req["url"]) == ("delete", url) + + def test_thread_emails_paths_and_bodies(self) -> None: + base = f"https://api.resend.com/inboxes/{INBOX_ID}/threads/{THREAD_ID}/emails" + resend.Inboxes.Threads.Emails.list(INBOX_ID, THREAD_ID, {"before": EMAIL_ID}) + assert self.last_request()["url"] == f"{base}?before={EMAIL_ID}" + + resend.Inboxes.Threads.Emails.get(INBOX_ID, THREAD_ID, EMAIL_ID) + assert self.last_request()["url"] == f"{base}/{EMAIL_ID}" + + attachment: resend.Attachment = {"filename": "note.txt", "content": "aGVsbG8="} + resend.Inboxes.Threads.Emails.reply( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "html": "

Done.

", + "reply_all": True, + "attachments": [attachment], + } + ) + req = self.last_request() + assert (req["method"], req["url"]) == ("post", f"{base}/{EMAIL_ID}/reply") + assert req["json"] == { + "html": "

Done.

", + "reply_all": True, + "attachments": [attachment], + } + + resend.Inboxes.Threads.Emails.forward( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": "colleague@example.org", + } + ) + req = self.last_request() + assert (req["method"], req["url"]) == ("post", f"{base}/{EMAIL_ID}/forward") + assert req["json"] == {"to": "colleague@example.org"} + + def test_drafts_paths_and_bodies(self) -> None: + base = f"https://api.resend.com/inboxes/{INBOX_ID}/drafts" + resend.Inboxes.Drafts.list(INBOX_ID, {"limit": 1}) + assert self.last_request()["url"] == f"{base}?limit=1" + + resend.Inboxes.Drafts.create( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "reply_to_email_id": EMAIL_ID, + "to": [], + } + ) + req = self.last_request() + assert (req["method"], req["url"]) == ("post", base) + assert req["json"] == { + "thread_id": THREAD_ID, + "reply_to_email_id": EMAIL_ID, + "to": [], + } + + resend.Inboxes.Drafts.get(INBOX_ID, DRAFT_ID) + assert self.last_request()["url"] == f"{base}/{DRAFT_ID}" + + resend.Inboxes.Drafts.update({"inbox_id": INBOX_ID, "id": DRAFT_ID, "to": None}) + req = self.last_request() + assert (req["method"], req["url"], req["json"]) == ( + "patch", + f"{base}/{DRAFT_ID}", + {"to": None}, + ) + + resend.Inboxes.Drafts.remove(INBOX_ID, DRAFT_ID) + req = self.last_request() + assert (req["method"], req["url"]) == ("delete", f"{base}/{DRAFT_ID}") + + resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) + req = self.last_request() + assert (req["method"], req["url"]) == ("post", f"{base}/{DRAFT_ID}/send") From 763749714cb31c19f997d52e8ca39bf4fc484e9f Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:09:48 -0300 Subject: [PATCH 03/20] docs(examples): add Inboxes examples --- examples/async/inboxes_async.py | 68 +++++++++++++++ examples/inboxes.py | 142 ++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 examples/async/inboxes_async.py create mode 100644 examples/inboxes.py diff --git a/examples/async/inboxes_async.py b/examples/async/inboxes_async.py new file mode 100644 index 0000000..8859b94 --- /dev/null +++ b/examples/async/inboxes_async.py @@ -0,0 +1,68 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +async def main() -> None: + created: resend.Inboxes.CreateResponse = await resend.Inboxes.create_async( + {"email_address": "support@acme.com", "name": "Customer Support"} + ) + inbox_id = created["id"] + print(f"Created inbox: {inbox_id}") + + inbox: resend.Inbox = await resend.Inboxes.get_async(inbox_id) + print(f"Retrieved inbox: {inbox['email_address']}") + + label: resend.Inboxes.Labels.CreateResponse = ( + await resend.Inboxes.Labels.create_async( + {"inbox_id": inbox_id, "name": "Urgent", "color": "#E93D82"} + ) + ) + + threads: resend.Inboxes.Threads.SearchResponse = ( + await resend.Inboxes.Threads.search_async( + inbox_id, {"query": "refund", "folders": ["inbox"]} + ) + ) + print(f"Found {len(threads['data'])} matching threads") + + if threads["data"]: + thread_id = threads["data"][0]["id"] + await resend.Inboxes.Threads.update_async( + {"inbox_id": inbox_id, "id": thread_id, "add_label_ids": [label["id"]]} + ) + emails: resend.Inboxes.Threads.Emails.ListResponse = ( + await resend.Inboxes.Threads.Emails.list_async(inbox_id, thread_id) + ) + reply: resend.Inboxes.Threads.Emails.ReplyResponse = ( + await resend.Inboxes.Threads.Emails.reply_async( + { + "inbox_id": inbox_id, + "thread_id": thread_id, + "email_id": emails["data"][-1]["id"], + "text": "Thanks, we are on it.", + } + ) + ) + print(f"Sent reply: {reply['id']}") + + draft: resend.Inboxes.Drafts.CreateResponse = ( + await resend.Inboxes.Drafts.create_async( + {"inbox_id": inbox_id, "to": "ada@example.org", "text": "Hello"} + ) + ) + sent: resend.Inboxes.Drafts.SendResponse = await resend.Inboxes.Drafts.send_async( + inbox_id, draft["id"] + ) + print(f"Sent draft as email: {sent['id']}") + + await resend.Inboxes.Labels.remove_async(inbox_id, label["id"]) + await resend.Inboxes.remove_async(inbox_id) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/inboxes.py b/examples/inboxes.py new file mode 100644 index 0000000..e9c59e3 --- /dev/null +++ b/examples/inboxes.py @@ -0,0 +1,142 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +create_params: resend.Inboxes.CreateParams = { + "email_address": "support@acme.com", + "name": "Customer Support", + "from_name": "Ada from Support", +} +created: resend.Inboxes.CreateResponse = resend.Inboxes.create(create_params) +inbox_id = created["id"] +print(f"Created inbox: {inbox_id}") + +inbox: resend.Inbox = resend.Inboxes.get("support@acme.com") +print(f"Retrieved inbox {inbox['email_address']} with {inbox['unread']} unread threads") + +inboxes: resend.Inboxes.ListResponse = resend.Inboxes.list({"limit": 10}) +print(f"Found {len(inboxes['data'])} inboxes") + +update_params: resend.Inboxes.UpdateParams = {"id": inbox_id, "name": "Support"} +resend.Inboxes.update(update_params) + +agent: resend.InboxAgentSettings = resend.Inboxes.Agent.get(inbox_id) +print(f"Agent actions: {agent['enabled_actions']}") +agent_params: resend.Inboxes.Agent.UpdateParams = { + "inbox_id": inbox_id, + "tone": "friendly and concise", + "enabled_actions": ["draft_reply", "add_labels"], +} +resend.Inboxes.Agent.update(agent_params) + +label_params: resend.Inboxes.Labels.CreateParams = { + "inbox_id": inbox_id, + "name": "Urgent", + "color": "#E93D82", +} +label: resend.Inboxes.Labels.CreateResponse = resend.Inboxes.Labels.create( + label_params +) +label_update: resend.Inboxes.Labels.UpdateParams = { + "inbox_id": inbox_id, + "id": label["id"], + "color": "#F76B15", +} +resend.Inboxes.Labels.update(label_update) +labels: resend.Inboxes.Labels.ListResponse = resend.Inboxes.Labels.list(inbox_id) +print(f"Found {len(labels['data'])} labels") + +threads: resend.Inboxes.Threads.ListResponse = resend.Inboxes.Threads.list( + inbox_id, {"folders": ["inbox", "sent"], "read": False, "limit": 10} +) +print(f"Found {len(threads['data'])} unread threads") + +search_params: resend.Inboxes.Threads.SearchParams = { + "query": "refund", + "has_attachment": False, +} +results: resend.Inboxes.Threads.SearchResponse = resend.Inboxes.Threads.search( + inbox_id, search_params +) +for result in results["data"]: + print(f"{result['subject']}: {result['highlights']}") + +if threads["data"]: + thread_id = threads["data"][0]["id"] + thread: resend.InboxThreadSummary = resend.Inboxes.Threads.get(inbox_id, thread_id) + print(f"Thread {thread['id']} is in {thread['folder']}") + + thread_update: resend.Inboxes.Threads.UpdateParams = { + "inbox_id": inbox_id, + "id": thread_id, + "read": True, + "add_label_ids": [label["id"]], + } + resend.Inboxes.Threads.update(thread_update) + + emails: resend.Inboxes.Threads.Emails.ListResponse = ( + resend.Inboxes.Threads.Emails.list(inbox_id, thread_id) + ) + email_id = emails["data"][-1]["id"] + email: resend.InboxThreadEmail = resend.Inboxes.Threads.Emails.get( + inbox_id, thread_id, email_id + ) + print(f"Latest email from {email['from']}: {email['subject']}") + + reply_params: resend.Inboxes.Threads.Emails.ReplyParams = { + "inbox_id": inbox_id, + "thread_id": thread_id, + "email_id": email_id, + "text": "Thanks, we are on it.", + "attachments": [{"filename": "note.txt", "content": "VGhhbmtzIQ=="}], + } + reply: resend.Inboxes.Threads.Emails.ReplyResponse = ( + resend.Inboxes.Threads.Emails.reply(reply_params) + ) + print(f"Sent reply: {reply['id']}") + + forward_params: resend.Inboxes.Threads.Emails.ForwardParams = { + "inbox_id": inbox_id, + "thread_id": thread_id, + "email_id": email_id, + "to": ["colleague@example.org"], + "text": "Flagging this for you.", + } + forward: resend.Inboxes.Threads.Emails.ForwardResponse = ( + resend.Inboxes.Threads.Emails.forward(forward_params) + ) + print(f"Sent forward: {forward['id']}") + + resend.Inboxes.Threads.remove(inbox_id, thread_id) + +draft_params: resend.Inboxes.Drafts.CreateParams = { + "inbox_id": inbox_id, + "to": ["ada@example.org"], + "subject": "Refund for order 1041", + "text": "Refund issued for order 1041.", +} +draft: resend.Inboxes.Drafts.CreateResponse = resend.Inboxes.Drafts.create( + draft_params +) +draft_update: resend.Inboxes.Drafts.UpdateParams = { + "inbox_id": inbox_id, + "id": draft["id"], + "html": "

Refund issued for order 1041.

", +} +updated_draft: resend.InboxDraft = resend.Inboxes.Drafts.update(draft_update) +print(f"Draft {updated_draft['id']} is a {updated_draft['type']} draft") + +drafts: resend.Inboxes.Drafts.ListResponse = resend.Inboxes.Drafts.list(inbox_id) +print(f"Found {len(drafts['data'])} drafts") + +sent: resend.Inboxes.Drafts.SendResponse = resend.Inboxes.Drafts.send( + inbox_id, draft["id"] +) +print(f"Sent draft as email: {sent['id']}") + +resend.Inboxes.Labels.remove(inbox_id, label["id"]) +removed: resend.Inboxes.RemoveResponse = resend.Inboxes.remove(inbox_id) +print(f"Removed inbox: {removed['deleted']}") From fc5d96b043d685134321ff1027ff683b3547ab11 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:09:48 -0300 Subject: [PATCH 04/20] feat(webhooks): add the inbox webhook event types Add the 14 inbox.* events to WebhookEvent, type their payloads, and add them to WebhookEventPayload. --- resend/__init__.py | 51 +++ resend/webhooks/_webhook.py | 15 + resend/webhooks/_webhook_event.py | 534 ++++++++++++++++++++++++++++++ 3 files changed, 600 insertions(+) diff --git a/resend/__init__.py b/resend/__init__.py index abdd79d..7501646 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -110,6 +110,30 @@ EmailFailedEvent, EmailOpenedEvent, EmailReceivedEvent, EmailScheduledEvent, EmailSentEvent, EmailSuppressedEvent, + InboxCreatedEvent, InboxDeletedEvent, + InboxDeletedEventData, + InboxDraftCreatedEvent, + InboxDraftDeletedEvent, + InboxDraftEventData, + InboxDraftSentEvent, + InboxDraftSentEventData, + InboxDraftUpdatedEvent, + InboxEmailReceivedEvent, + InboxEmailSentEvent, InboxEventData, + InboxEventDraft, InboxEventEmail, + InboxEventEmailAttachment, + InboxEventSource, + InboxThreadAssignedEvent, + InboxThreadAssignedEventData, + InboxThreadCreatedEvent, + InboxThreadEmailEventData, + InboxThreadFolderUpdatedEvent, + InboxThreadFolderUpdatedEventData, + InboxThreadLabelsUpdatedEvent, + InboxThreadLabelsUpdatedEventData, + InboxThreadUnassignedEvent, + InboxThreadUnassignedEventData, + InboxUpdatedEvent, ReceivedEmailEventData, SuppressionAddedEvent, SuppressionEventData, @@ -254,6 +278,33 @@ "TopicCreatedEvent", "TopicUpdatedEvent", "TopicDeletedEvent", + "InboxEventSource", + "InboxEventEmail", + "InboxEventEmailAttachment", + "InboxEventDraft", + "InboxEventData", + "InboxDeletedEventData", + "InboxThreadEmailEventData", + "InboxThreadFolderUpdatedEventData", + "InboxThreadAssignedEventData", + "InboxThreadUnassignedEventData", + "InboxThreadLabelsUpdatedEventData", + "InboxDraftEventData", + "InboxDraftSentEventData", + "InboxCreatedEvent", + "InboxUpdatedEvent", + "InboxDeletedEvent", + "InboxThreadCreatedEvent", + "InboxEmailReceivedEvent", + "InboxEmailSentEvent", + "InboxThreadFolderUpdatedEvent", + "InboxThreadAssignedEvent", + "InboxThreadUnassignedEvent", + "InboxThreadLabelsUpdatedEvent", + "InboxDraftCreatedEvent", + "InboxDraftUpdatedEvent", + "InboxDraftSentEvent", + "InboxDraftDeletedEvent", "Inbox", "InboxListItem", "InboxMessageFolder", diff --git a/resend/webhooks/_webhook.py b/resend/webhooks/_webhook.py index 4f916f9..49c3375 100644 --- a/resend/webhooks/_webhook.py +++ b/resend/webhooks/_webhook.py @@ -33,6 +33,21 @@ "topic.created", "topic.updated", "topic.deleted", + # Inbox events + "inbox.created", + "inbox.updated", + "inbox.deleted", + "inbox.thread.created", + "inbox.email.received", + "inbox.email.sent", + "inbox.thread.folder.updated", + "inbox.thread.assigned", + "inbox.thread.unassigned", + "inbox.thread.labels.updated", + "inbox.draft.created", + "inbox.draft.updated", + "inbox.draft.sent", + "inbox.draft.deleted", ] diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py index 5407e81..3bbd5b2 100644 --- a/resend/webhooks/_webhook_event.py +++ b/resend/webhooks/_webhook_event.py @@ -2,6 +2,9 @@ from typing_extensions import Literal, NotRequired, TypedDict +from resend.inboxes._inbox import Inbox, MoveThreadFolder +from resend.inboxes.threads._inbox_thread import (InboxThreadLabel, + InboxThreadSummary) from resend.suppressions._suppression import SuppressionListItem # Functional syntax required because ``from`` is a reserved keyword. @@ -429,6 +432,411 @@ class TopicEventData(TypedDict): """ +InboxEventSource = Literal["api", "dashboard", "agent", "system"] +""" +What made the change on an inbox webhook event. +""" + + +class InboxEventEmailAttachment(TypedDict): + """ + An attachment on an email in an inbox webhook event. + """ + + id: str + """ + The ID of the attachment. Not always a UUID. Outbound attachments are + identified by their position in the sent email. + """ + filename: Optional[str] + """ + The filename of the attachment. + """ + size: Optional[int] + """ + The size of the attachment in bytes. + """ + + +class InboxEventEmail(_FromField): + """ + The email carried by an inbox webhook event. It has no ``html`` or ``text``. + The ``from`` key holds the sender, as an address or as ``Name
``. + """ + + id: str + """ + The ID of the email. + """ + direction: Literal["inbound", "outbound"] + """ + Whether the email was received by the inbox or sent from it. + """ + to: List[str] + """ + The recipients of the email. + """ + cc: List[str] + """ + The CC recipients of the email. + """ + bcc: List[str] + """ + The BCC recipients of the email. + """ + reply_to: List[str] + """ + The Reply-To addresses. + """ + subject: Optional[str] + """ + The subject of the email. + """ + message_id: Optional[str] + """ + The Message-ID header of the email. + """ + attachments: List[InboxEventEmailAttachment] + """ + The attachments on the email. + """ + read: bool + """ + Whether the email has been read. + """ + received_at: str + """ + Timestamp when the email arrived or was sent. + """ + + +class InboxEventDraft(TypedDict): + """ + The draft carried by an inbox webhook event. It has no ``html`` or ``text``. + """ + + object: str + """ + The object type. Always ``inbox_draft``. + """ + id: str + """ + The ID of the draft. + """ + type: Literal["standalone", "reply"] + """ + ``standalone`` for a new conversation, or ``reply``. + """ + to: Optional[List[str]] + """ + The recipients. + """ + cc: List[str] + """ + The CC recipients. + """ + bcc: List[str] + """ + The BCC recipients. + """ + subject: Optional[str] + """ + The subject. + """ + thread_id: Optional[str] + """ + The ID of the thread when this draft is a reply. None otherwise. + """ + reply_to_email_id: Optional[str] + """ + The ID of the email this draft replies to. None otherwise. + """ + email_id: Optional[str] + """ + The ID of the queued outbound email after send. None until then. + """ + created_at: str + """ + Timestamp when the draft was created. + """ + updated_at: str + """ + Timestamp when the draft was last saved. + """ + + +class InboxEventData(TypedDict): + """ + ``data`` payload for ``inbox.created`` and ``inbox.updated`` events. + """ + + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + inbox: NotRequired[Inbox] + """ + The inbox as it is when the webhook is sent. Absent when the inbox no longer exists. + """ + + +class InboxDeletedEventData(TypedDict): + """ + ``data`` payload for ``inbox.deleted`` events. + """ + + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + + +class InboxThreadEmailEventData(TypedDict): + """ + ``data`` payload for ``inbox.thread.created``, ``inbox.email.received`` and + ``inbox.email.sent`` events. + """ + + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: str + """ + The ID of the thread. + """ + email_id: str + """ + The ID of the email. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no longer exists. + """ + email: NotRequired[InboxEventEmail] + """ + The email as it is when the webhook is sent, without ``html`` or ``text``. + Fetch the body with ``Inboxes.Threads.Emails.get``. Absent when the email no + longer exists. + """ + + +# Functional syntax required because ``from`` is a reserved keyword. +_InboxThreadFolderMove = TypedDict( + "_InboxThreadFolderMove", + { + "from": MoveThreadFolder, + "to": MoveThreadFolder, + }, +) + + +class InboxThreadFolderUpdatedEventData(_InboxThreadFolderMove): + """ + ``data`` payload for ``inbox.thread.folder.updated`` events. The ``from`` key + holds the folder the thread was in, and ``to`` the folder it moved to. + """ + + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: str + """ + The ID of the thread. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no longer exists. + """ + + +class InboxThreadAssignedEventData(TypedDict): + """ + ``data`` payload for ``inbox.thread.assigned`` events. + """ + + assignee_email: Optional[str] + """ + The email of the member the thread is now assigned to. + """ + assigned_by_email: Optional[str] + """ + The email of the member who made the assignment. None when no member made it. + """ + previous_assignee_email: Optional[str] + """ + The email of the member the thread was assigned to before. None when it was unassigned. + """ + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: str + """ + The ID of the thread. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no longer exists. + """ + + +class InboxThreadUnassignedEventData(TypedDict): + """ + ``data`` payload for ``inbox.thread.unassigned`` events. + """ + + previous_assignee_email: Optional[str] + """ + The email of the member the thread was assigned to. + """ + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: str + """ + The ID of the thread. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no longer exists. + """ + + +class InboxThreadLabelsUpdatedEventData(TypedDict): + """ + ``data`` payload for ``inbox.thread.labels.updated`` events. + One event carries one label, in either ``added`` or ``removed``. + """ + + added: List[InboxThreadLabel] + """ + The labels applied to the thread. + """ + removed: List[InboxThreadLabel] + """ + The labels removed from the thread. + """ + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: str + """ + The ID of the thread. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no longer exists. + """ + + +class InboxDraftEventData(TypedDict): + """ + ``data`` payload for ``inbox.draft.created``, ``inbox.draft.updated`` and + ``inbox.draft.deleted`` events. + """ + + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: NotRequired[str] + """ + The ID of the thread. Only on reply drafts. + """ + draft_id: str + """ + The ID of the draft. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no + longer exists. Only on reply drafts. + """ + draft: NotRequired[InboxEventDraft] + """ + The draft as it is when the webhook is sent, without ``html`` or ``text``. + Fetch the body with ``Inboxes.Drafts.get``. Absent when the draft no longer exists. + """ + + +class InboxDraftSentEventData(TypedDict): + """ + ``data`` payload for ``inbox.draft.sent`` events. + """ + + source: InboxEventSource + """ + What made the change. + """ + inbox_id: str + """ + The ID of the inbox. + """ + thread_id: str + """ + The ID of the thread. + """ + email_id: str + """ + The ID of the email. + """ + draft_id: str + """ + The ID of the draft. + """ + thread: NotRequired[InboxThreadSummary] + """ + The thread as it is when the webhook is sent. Absent when the thread no longer exists. + """ + email: NotRequired[InboxEventEmail] + """ + The email as it is when the webhook is sent, without ``html`` or ``text``. + Fetch the body with ``Inboxes.Threads.Emails.get``. Only when the email is + already on the thread when the webhook is sent. + """ + draft: NotRequired[InboxEventDraft] + """ + The draft as it is when the webhook is sent, without ``html`` or ``text``. + Fetch the body with ``Inboxes.Drafts.get``. Absent when the draft no longer exists. + """ + + class EmailSentEvent(TypedDict): """Webhook payload for ``email.sent``.""" @@ -613,6 +1021,118 @@ class TopicDeletedEvent(TypedDict): data: TopicEventData +class InboxCreatedEvent(TypedDict): + """Webhook payload for ``inbox.created``.""" + + type: Literal["inbox.created"] + created_at: str + data: InboxEventData + + +class InboxUpdatedEvent(TypedDict): + """Webhook payload for ``inbox.updated``.""" + + type: Literal["inbox.updated"] + created_at: str + data: InboxEventData + + +class InboxDeletedEvent(TypedDict): + """Webhook payload for ``inbox.deleted``.""" + + type: Literal["inbox.deleted"] + created_at: str + data: InboxDeletedEventData + + +class InboxThreadCreatedEvent(TypedDict): + """Webhook payload for ``inbox.thread.created``.""" + + type: Literal["inbox.thread.created"] + created_at: str + data: InboxThreadEmailEventData + + +class InboxEmailReceivedEvent(TypedDict): + """Webhook payload for ``inbox.email.received``.""" + + type: Literal["inbox.email.received"] + created_at: str + data: InboxThreadEmailEventData + + +class InboxEmailSentEvent(TypedDict): + """Webhook payload for ``inbox.email.sent``.""" + + type: Literal["inbox.email.sent"] + created_at: str + data: InboxThreadEmailEventData + + +class InboxThreadFolderUpdatedEvent(TypedDict): + """Webhook payload for ``inbox.thread.folder.updated``.""" + + type: Literal["inbox.thread.folder.updated"] + created_at: str + data: InboxThreadFolderUpdatedEventData + + +class InboxThreadAssignedEvent(TypedDict): + """Webhook payload for ``inbox.thread.assigned``.""" + + type: Literal["inbox.thread.assigned"] + created_at: str + data: InboxThreadAssignedEventData + + +class InboxThreadUnassignedEvent(TypedDict): + """Webhook payload for ``inbox.thread.unassigned``.""" + + type: Literal["inbox.thread.unassigned"] + created_at: str + data: InboxThreadUnassignedEventData + + +class InboxThreadLabelsUpdatedEvent(TypedDict): + """Webhook payload for ``inbox.thread.labels.updated``.""" + + type: Literal["inbox.thread.labels.updated"] + created_at: str + data: InboxThreadLabelsUpdatedEventData + + +class InboxDraftCreatedEvent(TypedDict): + """Webhook payload for ``inbox.draft.created``.""" + + type: Literal["inbox.draft.created"] + created_at: str + data: InboxDraftEventData + + +class InboxDraftUpdatedEvent(TypedDict): + """Webhook payload for ``inbox.draft.updated``.""" + + type: Literal["inbox.draft.updated"] + created_at: str + data: InboxDraftEventData + + +class InboxDraftSentEvent(TypedDict): + """Webhook payload for ``inbox.draft.sent``.""" + + type: Literal["inbox.draft.sent"] + created_at: str + data: InboxDraftSentEventData + + +class InboxDraftDeletedEvent(TypedDict): + """Webhook payload for ``inbox.draft.deleted``.""" + + type: Literal["inbox.draft.deleted"] + created_at: str + data: InboxDraftEventData + + WebhookEventPayload = Union[ EmailSentEvent, EmailScheduledEvent, @@ -637,6 +1157,20 @@ class TopicDeletedEvent(TypedDict): TopicCreatedEvent, TopicUpdatedEvent, TopicDeletedEvent, + InboxCreatedEvent, + InboxUpdatedEvent, + InboxDeletedEvent, + InboxThreadCreatedEvent, + InboxEmailReceivedEvent, + InboxEmailSentEvent, + InboxThreadFolderUpdatedEvent, + InboxThreadAssignedEvent, + InboxThreadUnassignedEvent, + InboxThreadLabelsUpdatedEvent, + InboxDraftCreatedEvent, + InboxDraftUpdatedEvent, + InboxDraftSentEvent, + InboxDraftDeletedEvent, ] """ Union of all Resend webhook event payload shapes returned by ``Webhooks.verify``. From f7f70f63db478d4b0eb88834cb197571c3029f29 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:09:48 -0300 Subject: [PATCH 05/20] chore: bump version to 2.50.0 --- resend/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resend/version.py b/resend/version.py index 4ccbcac..79982a3 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.49.1" +__version__ = "2.50.0" def get_version() -> str: From 5a38b38b7d7c7a5d9f334555ab80c845152087bd Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:12:29 -0300 Subject: [PATCH 06/20] test(inboxes): assert the path of every async method The async methods build their own paths. Check the URL that each one requests, as the sync request test does. --- tests/inboxes_async_test.py | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/inboxes_async_test.py b/tests/inboxes_async_test.py index 5962965..66b6a4e 100644 --- a/tests/inboxes_async_test.py +++ b/tests/inboxes_async_test.py @@ -448,3 +448,98 @@ async def test_should_send_inbox_drafts_async_raise_exception_when_no_content( self.set_mock_json(None) with pytest.raises(NoContentError): _ = await resend.Inboxes.Drafts.send_async(INBOX_ID, DRAFT_ID) + + +class TestInboxesAsyncPaths(AsyncResendBaseTest): + def last_url(self) -> str: + return str(self.mock.call_args.kwargs["url"]) + + async def test_every_async_method_calls_its_path(self) -> None: + self.set_mock_json({"object": "inbox", "id": INBOX_ID}) + api = "https://api.resend.com" + inbox = f"{api}/inboxes/{INBOX_ID}" + thread = f"{inbox}/threads/{THREAD_ID}" + email = f"{thread}/emails/{EMAIL_ID}" + draft = f"{inbox}/drafts/{DRAFT_ID}" + + await resend.Inboxes.create_async({"email_address": "support@acme.com"}) + assert self.last_url() == f"{api}/inboxes" + await resend.Inboxes.list_async({"limit": 1}) + assert self.last_url() == f"{api}/inboxes?limit=1" + await resend.Inboxes.get_async("support@acme.com") + assert self.last_url() == f"{api}/inboxes/support%40acme.com" + await resend.Inboxes.update_async({"id": INBOX_ID, "name": "Support"}) + assert self.last_url() == inbox + await resend.Inboxes.remove_async(INBOX_ID) + assert self.last_url() == inbox + + await resend.Inboxes.Agent.get_async(INBOX_ID) + assert self.last_url() == f"{inbox}/agent" + await resend.Inboxes.Agent.update_async({"inbox_id": INBOX_ID, "tone": None}) + assert self.last_url() == f"{inbox}/agent" + + await resend.Inboxes.Labels.list_async(INBOX_ID) + assert self.last_url() == f"{inbox}/labels" + await resend.Inboxes.Labels.create_async({"inbox_id": INBOX_ID, "name": "Urgent"}) + assert self.last_url() == f"{inbox}/labels" + await resend.Inboxes.Labels.update_async( + {"inbox_id": INBOX_ID, "id": LABEL_ID, "name": "Billing"} + ) + assert self.last_url() == f"{inbox}/labels/{LABEL_ID}" + await resend.Inboxes.Labels.remove_async(INBOX_ID, LABEL_ID) + assert self.last_url() == f"{inbox}/labels/{LABEL_ID}" + + await resend.Inboxes.Threads.list_async( + INBOX_ID, {"folders": ["inbox", "sent"], "read": True} + ) + assert self.last_url() == f"{inbox}/threads?folders=inbox%2Csent&read=true" + await resend.Inboxes.Threads.search_async( + INBOX_ID, {"to": ["a@example.com"], "has_attachment": False} + ) + assert ( + self.last_url() + == f"{inbox}/threads/search?to=a%40example.com&has_attachment=false" + ) + await resend.Inboxes.Threads.get_async(INBOX_ID, THREAD_ID) + assert self.last_url() == thread + await resend.Inboxes.Threads.update_async( + {"inbox_id": INBOX_ID, "id": THREAD_ID, "read": True} + ) + assert self.last_url() == thread + await resend.Inboxes.Threads.remove_async(INBOX_ID, THREAD_ID) + assert self.last_url() == thread + + await resend.Inboxes.Threads.Emails.list_async( + INBOX_ID, THREAD_ID, {"after": EMAIL_ID} + ) + assert self.last_url() == f"{thread}/emails?after={EMAIL_ID}" + await resend.Inboxes.Threads.Emails.get_async(INBOX_ID, THREAD_ID, EMAIL_ID) + assert self.last_url() == email + await resend.Inboxes.Threads.Emails.reply_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "email_id": EMAIL_ID, "text": "Hi"} + ) + assert self.last_url() == f"{email}/reply" + await resend.Inboxes.Threads.Emails.forward_async( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": "colleague@example.org", + } + ) + assert self.last_url() == f"{email}/forward" + + await resend.Inboxes.Drafts.list_async(INBOX_ID, {"limit": 1}) + assert self.last_url() == f"{inbox}/drafts?limit=1" + await resend.Inboxes.Drafts.create_async({"inbox_id": INBOX_ID, "text": "Hi"}) + assert self.last_url() == f"{inbox}/drafts" + await resend.Inboxes.Drafts.get_async(INBOX_ID, DRAFT_ID) + assert self.last_url() == draft + await resend.Inboxes.Drafts.update_async( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "text": "Hello"} + ) + assert self.last_url() == draft + await resend.Inboxes.Drafts.remove_async(INBOX_ID, DRAFT_ID) + assert self.last_url() == draft + await resend.Inboxes.Drafts.send_async(INBOX_ID, DRAFT_ID) + assert self.last_url() == f"{draft}/send" From 765ee17d93db632117d5319de710d7e8efb6d22f Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:19:00 -0300 Subject: [PATCH 07/20] fix(inboxes): leave empty list filters out of the query An empty folders or labels list sent folders= with no value. Leave it out, as the email metrics query does. --- resend/inboxes/_query.py | 7 ++++--- tests/inboxes_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/resend/inboxes/_query.py b/resend/inboxes/_query.py index ac1c12a..4e19626 100644 --- a/resend/inboxes/_query.py +++ b/resend/inboxes/_query.py @@ -6,8 +6,8 @@ def build_inbox_query_path(path: str, params: Optional[Mapping[str, Any]] = None) -> str: """ Build a path with the query parameters of an inbox list or search call. - Lists become comma-separated values, and booleans become the literal - ``true`` or ``false`` that the API accepts. + Lists become comma-separated values, and empty lists are left out. + Booleans become the literal ``true`` or ``false`` that the API accepts. Args: path: The base API path (e.g., "/inboxes/{inbox_id}/threads") @@ -26,7 +26,8 @@ def build_inbox_query_path(path: str, params: Optional[Mapping[str, Any]] = None if isinstance(value, bool): query[key] = "true" if value else "false" elif isinstance(value, (list, tuple)): - query[key] = ",".join(str(item) for item in value) + if value: + query[key] = ",".join(str(item) for item in value) else: query[key] = value diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index edf350f..c672bf5 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -741,6 +741,13 @@ def test_threads_list_joins_lists_and_lowercases_booleans(self) -> None: f"?folders=inbox%2Csent&labels={LABEL_ID}&read=false" ) + def test_threads_list_leaves_out_empty_lists(self) -> None: + resend.Inboxes.Threads.list(INBOX_ID, {"folders": [], "labels": [], "limit": 5}) + assert ( + self.last_request()["url"] + == f"https://api.resend.com/inboxes/{INBOX_ID}/threads?limit=5" + ) + def test_threads_search_builds_the_query(self) -> None: params: resend.Inboxes.Threads.SearchParams = { "query": "invoice -draft", From 90d048621aa0edf8ed5226a2952107cd20c08d42 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 12 Oct 2026 14:19:00 -0300 Subject: [PATCH 08/20] docs(examples): reply to the first email of the thread The list returns the oldest emails first and can have more pages, so the last item of the first page is not always the latest email. --- examples/async/inboxes_async.py | 2 +- examples/inboxes.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/async/inboxes_async.py b/examples/async/inboxes_async.py index 8859b94..586c254 100644 --- a/examples/async/inboxes_async.py +++ b/examples/async/inboxes_async.py @@ -43,7 +43,7 @@ async def main() -> None: { "inbox_id": inbox_id, "thread_id": thread_id, - "email_id": emails["data"][-1]["id"], + "email_id": emails["data"][0]["id"], "text": "Thanks, we are on it.", } ) diff --git a/examples/inboxes.py b/examples/inboxes.py index e9c59e3..59e4c18 100644 --- a/examples/inboxes.py +++ b/examples/inboxes.py @@ -80,11 +80,11 @@ emails: resend.Inboxes.Threads.Emails.ListResponse = ( resend.Inboxes.Threads.Emails.list(inbox_id, thread_id) ) - email_id = emails["data"][-1]["id"] + email_id = emails["data"][0]["id"] email: resend.InboxThreadEmail = resend.Inboxes.Threads.Emails.get( inbox_id, thread_id, email_id ) - print(f"Latest email from {email['from']}: {email['subject']}") + print(f"First email from {email['from']}: {email['subject']}") reply_params: resend.Inboxes.Threads.Emails.ReplyParams = { "inbox_id": inbox_id, From a162fb739d66d99c4c70322f83b341a686a2fcba Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 09:40:55 -0300 Subject: [PATCH 09/20] fix(inboxes): remove label_id from the thread update params The API removed label_id from PATCH /inboxes/{inbox_id}/threads/{thread_id}. Use add_label_ids and remove_label_ids. Removing a label from a thread keeps the label itself. --- resend/inboxes/threads/_inbox_threads.py | 31 ++++++++++-------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/resend/inboxes/threads/_inbox_threads.py b/resend/inboxes/threads/_inbox_threads.py index 2add23a..bfe4acd 100644 --- a/resend/inboxes/threads/_inbox_threads.py +++ b/resend/inboxes/threads/_inbox_threads.py @@ -196,15 +196,14 @@ class SearchResponse(BaseResponse): class UpdateParams(TypedDict): """ UpdateParams are the parameters for updating a thread. At least one of - ``read``, ``folder``, ``label_id``, ``add_label_ids``, or ``remove_label_ids`` - is required. A label ID cannot be both added and removed in the same request. + ``read``, ``folder``, ``add_label_ids``, or ``remove_label_ids`` is required. + A label ID cannot be both added and removed in the same request. Attributes: inbox_id (str): The Inbox ID. An email address is not accepted. id (str): The Thread ID. read (NotRequired[bool]): Marks every email in the thread read or unread. folder (NotRequired[MoveThreadFolder]): Moves the thread to this folder. - label_id (NotRequired[str]): Deprecated. Use add_label_ids instead. add_label_ids (NotRequired[List[str]]): Label IDs to apply to the thread. remove_label_ids (NotRequired[List[str]]): Label IDs to remove from the thread. """ @@ -227,12 +226,6 @@ class UpdateParams(TypedDict): cannot be set. Moving a thread out of ``trash`` restores it and clears its deletion deadline. """ - label_id: NotRequired[str] - """ - Deprecated. Use ``add_label_ids`` instead. Applies this label to the - thread, the same as one entry in ``add_label_ids``. A label ID that does - not exist in the inbox returns a 404. - """ add_label_ids: NotRequired[List[str]] """ Label IDs to apply to the thread. Between 1 and 10 IDs. @@ -240,8 +233,8 @@ class UpdateParams(TypedDict): """ remove_label_ids: NotRequired[List[str]] """ - Label IDs to remove from the thread. Between 1 and 10 IDs. - A label ID that does not exist in the inbox returns a 404. + Label IDs to remove from the thread. Between 1 and 10 IDs. The labels + themselves are kept. A label ID that does not exist in the inbox returns a 404. """ class RemoveResponse(BaseResponse): @@ -345,10 +338,11 @@ def get(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: def update(cls, params: UpdateParams) -> InboxThreadSummary: """ Mark a thread read or unread, move it to a folder, or add and remove labels. - At least one of ``read``, ``folder``, ``label_id``, ``add_label_ids``, or + At least one of ``read``, ``folder``, ``add_label_ids``, or ``remove_label_ids`` is required. A label ID cannot be both added and - removed in the same request. Moving a thread out of ``trash`` restores it - and clears its deletion deadline. + removed in the same request. Removing a label from a thread keeps the + label itself. Moving a thread out of ``trash`` restores it and clears its + deletion deadline. see more: https://resend.com/docs/api-reference/inboxes/update-thread Args: @@ -465,10 +459,11 @@ async def get_async(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: async def update_async(cls, params: UpdateParams) -> InboxThreadSummary: """ Mark a thread read or unread, move it to a folder, or add and remove - labels (async). At least one of ``read``, ``folder``, ``label_id``, - ``add_label_ids``, or ``remove_label_ids`` is required. A label ID cannot - be both added and removed in the same request. Moving a thread out of - ``trash`` restores it and clears its deletion deadline. + labels (async). At least one of ``read``, ``folder``, ``add_label_ids``, + or ``remove_label_ids`` is required. A label ID cannot be both added and + removed in the same request. Removing a label from a thread keeps the + label itself. Moving a thread out of ``trash`` restores it and clears its + deletion deadline. see more: https://resend.com/docs/api-reference/inboxes/update-thread Args: From 8207c6d4a6bc49d2dce8fe50d67b76d158720677 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 09:43:06 -0300 Subject: [PATCH 10/20] feat(inboxes): add attachments to drafts Drafts.create and Drafts.update accept attachments. Each one needs content, filename and content_type. On update, the list replaces every attachment on the draft, and [] removes them all. InboxDraft, InboxDraftListItem and the webhook draft object return the stored attachments as InboxDraftAttachment. --- resend/__init__.py | 5 +- resend/inboxes/__init__.py | 5 +- resend/inboxes/drafts/_inbox_draft.py | 31 +++++++++++ resend/inboxes/drafts/_inbox_drafts.py | 74 +++++++++++++++++++++----- resend/webhooks/_webhook_event.py | 5 ++ tests/inboxes_test.py | 37 +++++++++++++ 6 files changed, 141 insertions(+), 16 deletions(-) diff --git a/resend/__init__.py b/resend/__init__.py index 7501646..e234095 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -59,8 +59,8 @@ from .inboxes.agent._inbox_agent import InboxAgent from .inboxes.agent._inbox_agent_settings import (InboxAgentAction, InboxAgentSettings) -from .inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftListItem, - InboxDraftType) +from .inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftAttachment, + InboxDraftListItem, InboxDraftType) from .inboxes.drafts._inbox_drafts import InboxDrafts from .inboxes.labels._inbox_label import InboxLabel from .inboxes.labels._inbox_labels import InboxLabels @@ -320,6 +320,7 @@ "InboxThreadEmail", "InboxThreadEmailAttachment", "InboxDraft", + "InboxDraftAttachment", "InboxDraftListItem", "InboxDraftType", "Topic", diff --git a/resend/inboxes/__init__.py b/resend/inboxes/__init__.py index bca24d4..0461ffa 100644 --- a/resend/inboxes/__init__.py +++ b/resend/inboxes/__init__.py @@ -4,7 +4,9 @@ from resend.inboxes.agent._inbox_agent import InboxAgent from resend.inboxes.agent._inbox_agent_settings import (InboxAgentAction, InboxAgentSettings) -from resend.inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftListItem, +from resend.inboxes.drafts._inbox_draft import (InboxDraft, + InboxDraftAttachment, + InboxDraftListItem, InboxDraftType) from resend.inboxes.drafts._inbox_drafts import InboxDrafts from resend.inboxes.labels._inbox_label import InboxLabel @@ -42,6 +44,7 @@ "InboxThreadEmail", "InboxThreadEmailAttachment", "InboxDraft", + "InboxDraftAttachment", "InboxDraftListItem", "InboxDraftType", ] diff --git a/resend/inboxes/drafts/_inbox_draft.py b/resend/inboxes/drafts/_inbox_draft.py index bf89844..375c520 100644 --- a/resend/inboxes/drafts/_inbox_draft.py +++ b/resend/inboxes/drafts/_inbox_draft.py @@ -11,6 +11,29 @@ """ +class InboxDraftAttachment(TypedDict): + """ + InboxDraftAttachment is a file stored on a draft. + """ + + id: str + """ + The ID of the attachment. + """ + filename: str + """ + The filename of the attachment. + """ + content_type: str + """ + The MIME type of the attachment. + """ + size: int + """ + The size of the attachment in bytes. + """ + + class InboxDraft(BaseResponse): """ InboxDraft is an unsent or sent draft of an inbox. @@ -74,6 +97,10 @@ class InboxDraft(BaseResponse): """ Timestamp when the draft was last saved. """ + attachments: List[InboxDraftAttachment] + """ + The files stored on the draft. + """ class InboxDraftListItem(TypedDict): @@ -122,3 +149,7 @@ class InboxDraftListItem(TypedDict): """ Timestamp when the draft was last saved. """ + attachments: List[InboxDraftAttachment] + """ + The files stored on the draft. + """ diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py index 44aa35e..1ea4150 100644 --- a/resend/inboxes/drafts/_inbox_drafts.py +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -20,6 +20,31 @@ class InboxDrafts: InboxDrafts manages the drafts of an inbox. """ + class AttachmentParams(TypedDict): + """ + AttachmentParams is a file to store on a draft. Each one needs + ``content``, ``filename`` and ``content_type``. A ``path`` or + ``content_id`` returns a 422. + + Attributes: + content (str): The content of the file, encoded in base64. + filename (str): The name of the file. + content_type (str): The MIME type of the file. + """ + + content: str + """ + The content of the file, encoded in base64. + """ + filename: str + """ + The name of the file. + """ + content_type: str + """ + The MIME type of the file, for example ``application/pdf``. + """ + class ListParams(TypedDict): """ ListParams are the pagination parameters for listing drafts. @@ -72,7 +97,8 @@ class ListResponse(BaseResponse): class CreateParams(TypedDict): """ CreateParams are the parameters for creating a draft. At least one of - ``to``, ``cc``, ``bcc``, ``subject``, ``text`` or ``html`` must be non-empty. + ``to``, ``cc``, ``bcc``, ``subject``, ``text``, ``html`` or ``attachments`` + must be non-empty. The combined recipients of ``to``, ``cc`` and ``bcc`` cannot exceed 50. ``thread_id`` and ``reply_to_email_id`` must be sent together or not at all. @@ -84,6 +110,7 @@ class CreateParams(TypedDict): subject (NotRequired[Optional[str]]): The subject. html (NotRequired[Optional[str]]): The HTML body. text (NotRequired[Optional[str]]): The plain text body. + attachments (NotRequired[List[AttachmentParams]]): Files to store on the draft. thread_id (NotRequired[str]): The Thread ID to reply in. reply_to_email_id (NotRequired[str]): The Email ID to reply to. """ @@ -118,6 +145,12 @@ class CreateParams(TypedDict): """ The plain text body. """ + attachments: NotRequired[List["InboxDrafts.AttachmentParams"]] + """ + Files to store on the draft. Each one needs ``content``, ``filename`` and + ``content_type``. A ``path`` or ``content_id`` returns a 422. A draft can + carry up to 10 files and 25 MB in total. + """ thread_id: NotRequired[str] """ The Thread ID to reply in. Must be sent with ``reply_to_email_id``. @@ -149,8 +182,8 @@ class CreateResponse(BaseResponse): class UpdateParams(TypedDict): """ UpdateParams are the parameters for updating a draft. Send at least one of - ``to``, ``cc``, ``bcc``, ``subject``, ``text`` or ``html``. Omitted fields - are left unchanged. Setting ``text`` or ``html`` replaces the whole body, so + ``to``, ``cc``, ``bcc``, ``subject``, ``text``, ``html`` or ``attachments``. + Omitted fields are left unchanged. Setting ``text`` or ``html`` replaces the whole body, so the body part you omit becomes None. The merged draft must contain at least one non-empty field, and its combined recipients cannot exceed 50. @@ -163,6 +196,8 @@ class UpdateParams(TypedDict): subject (NotRequired[Optional[str]]): The subject. html (NotRequired[Optional[str]]): The HTML body. text (NotRequired[Optional[str]]): The plain text body. + attachments (NotRequired[List[AttachmentParams]]): Files that replace + every attachment on the draft. """ inbox_id: str @@ -199,6 +234,13 @@ class UpdateParams(TypedDict): """ The plain text body. Pass None to clear. """ + attachments: NotRequired[List["InboxDrafts.AttachmentParams"]] + """ + Files to store on the draft. Each one needs ``content``, ``filename`` and + ``content_type``. A ``path`` or ``content_id`` returns a 422. A draft can + carry up to 10 files and 25 MB in total. Sending the field replaces every + attachment on the draft. Send ``[]`` to remove them all. + """ class RemoveResponse(BaseResponse): """ @@ -267,9 +309,11 @@ def create(cls, params: CreateParams) -> CreateResponse: together for a reply draft in an existing thread. A reply draft without ``to`` is addressed to the sender of the email it replies to, or to its Reply-To when set. For an email the inbox sent, it defaults to that email's - ``to`` recipients. Send ``to: []`` to leave it empty. Only one unsent reply - draft can target an email, so a reply draft request for an email that - already has one returns the ID of the existing draft. + ``to`` recipients. Send ``to: []`` to leave it empty. Attachments are + stored on the draft, so each one needs ``content``, ``filename`` and + ``content_type``. Only one unsent reply draft can target an email, so a + reply draft request for an email that already has one returns the ID of + the existing draft. see more: https://resend.com/docs/api-reference/inboxes/create-draft Args: @@ -307,9 +351,10 @@ def get(cls, inbox_id: str, draft_id: str) -> InboxDraft: def update(cls, params: UpdateParams) -> InboxDraft: """ Update the content of an unsent draft. Omitted fields are left unchanged. - A request that sets ``text`` or ``html`` replaces the whole body. The body - part you omit from that request becomes None. A draft that was already - sent, or that changed during the request, returns a 409. + Sending ``attachments`` replaces every attachment on the draft. A request + that sets ``text`` or ``html`` replaces the whole body. The body part you + omit from that request becomes None. A draft that was already sent, or + that changed during the request, returns a 409. see more: https://resend.com/docs/api-reference/inboxes/update-draft Args: @@ -399,9 +444,11 @@ async def create_async(cls, params: CreateParams) -> CreateResponse: together for a reply draft in an existing thread. A reply draft without ``to`` is addressed to the sender of the email it replies to, or to its Reply-To when set. For an email the inbox sent, it defaults to that email's - ``to`` recipients. Send ``to: []`` to leave it empty. Only one unsent reply - draft can target an email, so a reply draft request for an email that - already has one returns the ID of the existing draft. + ``to`` recipients. Send ``to: []`` to leave it empty. Attachments are + stored on the draft, so each one needs ``content``, ``filename`` and + ``content_type``. Only one unsent reply draft can target an email, so a + reply draft request for an email that already has one returns the ID of + the existing draft. see more: https://resend.com/docs/api-reference/inboxes/create-draft Args: @@ -439,7 +486,8 @@ async def get_async(cls, inbox_id: str, draft_id: str) -> InboxDraft: async def update_async(cls, params: UpdateParams) -> InboxDraft: """ Update the content of an unsent draft (async). Omitted fields are left - unchanged. A request that sets ``text`` or ``html`` replaces the whole body. + unchanged. Sending ``attachments`` replaces every attachment on the draft. + A request that sets ``text`` or ``html`` replaces the whole body. The body part you omit from that request becomes None. A draft that was already sent, or that changed during the request, returns a 409. see more: https://resend.com/docs/api-reference/inboxes/update-draft diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py index 3bbd5b2..d5ee24e 100644 --- a/resend/webhooks/_webhook_event.py +++ b/resend/webhooks/_webhook_event.py @@ -3,6 +3,7 @@ from typing_extensions import Literal, NotRequired, TypedDict from resend.inboxes._inbox import Inbox, MoveThreadFolder +from resend.inboxes.drafts._inbox_draft import InboxDraftAttachment from resend.inboxes.threads._inbox_thread import (InboxThreadLabel, InboxThreadSummary) from resend.suppressions._suppression import SuppressionListItem @@ -563,6 +564,10 @@ class InboxEventDraft(TypedDict): """ Timestamp when the draft was last saved. """ + attachments: List[InboxDraftAttachment] + """ + The files stored on the draft. + """ class InboxEventData(TypedDict): diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index c672bf5..77ea577 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -80,6 +80,13 @@ "received_at": "2026-08-05T14:03:11.229Z", } +DRAFT_ATTACHMENT: Dict[str, Any] = { + "id": "5c1e9d2a-7b3f-4e8a-9c6d-2f0a1b3c4d5e", + "filename": "receipt.pdf", + "content_type": "application/pdf", + "size": 20841, +} + DRAFT: Dict[str, Any] = { "object": "inbox_draft", "id": DRAFT_ID, @@ -95,6 +102,7 @@ "email_id": None, "created_at": "2026-08-05T14:12:04.110Z", "updated_at": "2026-08-05T14:12:04.110Z", + "attachments": [DRAFT_ATTACHMENT], } @@ -532,6 +540,7 @@ def test_inbox_drafts_list(self) -> None: "thread_id": None, "reply_to_email_id": None, "updated_at": "2026-08-05T14:12:04.110Z", + "attachments": [], } ], } @@ -542,6 +551,7 @@ def test_inbox_drafts_list(self) -> None: assert drafts["object"] == "list" assert drafts["data"][0]["id"] == DRAFT_ID assert drafts["data"][0]["snippet"] == "Refund issued for order 1041." + assert drafts["data"][0]["attachments"] == [] def test_should_list_inbox_drafts_raise_exception_when_no_content( self, @@ -557,6 +567,13 @@ def test_inbox_drafts_create(self) -> None: "to": ["ada@example.org"], "subject": "Refund for order 1041", "text": "Refund issued for order 1041.", + "attachments": [ + { + "content": "aGVsbG8=", + "filename": "note.txt", + "content_type": "text/plain", + } + ], } draft: resend.Inboxes.Drafts.CreateResponse = resend.Inboxes.Drafts.create( params @@ -578,6 +595,8 @@ def test_inbox_drafts_get(self) -> None: assert draft["id"] == DRAFT_ID assert draft["type"] == "standalone" assert draft["email_id"] is None + assert draft["attachments"][0]["content_type"] == "application/pdf" + assert draft["attachments"][0]["size"] == 20841 def test_should_get_inbox_drafts_raise_exception_when_no_content( self, @@ -860,6 +879,24 @@ def test_drafts_paths_and_bodies(self) -> None: {"to": None}, ) + attachment: resend.Inboxes.Drafts.AttachmentParams = { + "content": "aGVsbG8=", + "filename": "note.txt", + "content_type": "text/plain", + } + resend.Inboxes.Drafts.create( + {"inbox_id": INBOX_ID, "to": "ada@example.org", "attachments": [attachment]} + ) + assert self.last_request()["json"] == { + "to": "ada@example.org", + "attachments": [attachment], + } + + resend.Inboxes.Drafts.update( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "attachments": []} + ) + assert self.last_request()["json"] == {"attachments": []} + resend.Inboxes.Drafts.remove(INBOX_ID, DRAFT_ID) req = self.last_request() assert (req["method"], req["url"]) == ("delete", f"{base}/{DRAFT_ID}") From fe14e17307f571693eaac211ef5621356b7e582c Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 09:43:06 -0300 Subject: [PATCH 11/20] docs(examples): attach a file to the example draft --- examples/inboxes.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/inboxes.py b/examples/inboxes.py index 59e4c18..c83bf63 100644 --- a/examples/inboxes.py +++ b/examples/inboxes.py @@ -117,6 +117,13 @@ "to": ["ada@example.org"], "subject": "Refund for order 1041", "text": "Refund issued for order 1041.", + "attachments": [ + { + "content": "UmVmdW5kIHJlY2VpcHQ=", + "filename": "receipt.txt", + "content_type": "text/plain", + } + ], } draft: resend.Inboxes.Drafts.CreateResponse = resend.Inboxes.Drafts.create( draft_params @@ -128,6 +135,7 @@ } updated_draft: resend.InboxDraft = resend.Inboxes.Drafts.update(draft_update) print(f"Draft {updated_draft['id']} is a {updated_draft['type']} draft") +print(f"Draft attachments: {[a['filename'] for a in updated_draft['attachments']]}") drafts: resend.Inboxes.Drafts.ListResponse = resend.Inboxes.Drafts.list(inbox_id) print(f"Found {len(drafts['data'])} drafts") From 7e2d4101e599db66ee6419721730e746adb6806b Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 10:27:55 -0300 Subject: [PATCH 12/20] docs(inboxes): align the doc comments with the API audit Draft attachments have no file-count limit, only 25 MB per draft. Sending a draft needs a to recipient, a subject, and html or text. Add the notes on reply_all recipients, forwarded path attachments, the order of thread updates, search folders, cursors, the 451 on inbox create, and agent action duplicates. Webhook email attachment IDs are stored IDs. --- resend/inboxes/_inboxes.py | 14 +++++--- resend/inboxes/agent/_inbox_agent.py | 4 +-- resend/inboxes/drafts/_inbox_drafts.py | 31 ++++++++++------- resend/inboxes/threads/_inbox_threads.py | 33 ++++++++++++------- .../threads/emails/_inbox_thread_emails.py | 25 +++++++++----- resend/webhooks/_webhook_event.py | 3 +- 6 files changed, 71 insertions(+), 39 deletions(-) diff --git a/resend/inboxes/_inboxes.py b/resend/inboxes/_inboxes.py index a97e35e..39dc0d0 100644 --- a/resend/inboxes/_inboxes.py +++ b/resend/inboxes/_inboxes.py @@ -226,7 +226,8 @@ def create(cls, params: CreateParams) -> CreateResponse: ``receiving_address`` on the team inbound domain. A domain that does not meet these rules returns a 422 validation_error. An address or receiving address that is already in use returns a 409. A team that has reached the - inbox limit of its plan returns a 403 validation_error. + inbox limit of its plan returns a 403 validation_error. A blocked account + returns a 451. see more: https://resend.com/docs/api-reference/inboxes/create-inbox Args: @@ -245,7 +246,9 @@ def create(cls, params: CreateParams) -> CreateResponse: def list(cls, params: Optional[ListParams] = None) -> ListResponse: """ Retrieve the inboxes of the team, newest first. The list items carry fewer - fields than a single inbox. Retrieve an inbox to get all its fields. + fields than a single inbox. Retrieve an inbox to get all its fields. The + ``after`` and ``before`` cursors take an inbox ID. A value that is not a + UUID returns a 422, and an unknown ID returns a 404. see more: https://resend.com/docs/api-reference/inboxes/list-inboxes Args: @@ -331,7 +334,8 @@ async def create_async(cls, params: CreateParams) -> CreateResponse: ``receiving_address`` on the team inbound domain. A domain that does not meet these rules returns a 422 validation_error. An address or receiving address that is already in use returns a 409. A team that has reached the - inbox limit of its plan returns a 403 validation_error. + inbox limit of its plan returns a 403 validation_error. A blocked account + returns a 451. see more: https://resend.com/docs/api-reference/inboxes/create-inbox Args: @@ -350,7 +354,9 @@ async def create_async(cls, params: CreateParams) -> CreateResponse: async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: """ Retrieve the inboxes of the team, newest first (async). The list items - carry fewer fields than a single inbox. Retrieve an inbox to get all its fields. + carry fewer fields than a single inbox. Retrieve an inbox to get all its + fields. The ``after`` and ``before`` cursors take an inbox ID. A value that + is not a UUID returns a 422, and an unknown ID returns a 404. see more: https://resend.com/docs/api-reference/inboxes/list-inboxes Args: diff --git a/resend/inboxes/agent/_inbox_agent.py b/resend/inboxes/agent/_inbox_agent.py index 515fa5f..72b5d23 100644 --- a/resend/inboxes/agent/_inbox_agent.py +++ b/resend/inboxes/agent/_inbox_agent.py @@ -51,8 +51,8 @@ class UpdateParams(TypedDict): """ enabled_actions: NotRequired[List[InboxAgentAction]] """ - The actions the agent can take. Replaces the whole set. - Send an empty list to disable all actions. + The actions the agent can take. Replaces the whole set. Duplicates are + removed. Send an empty list to disable all actions. """ class UpdateResponse(BaseResponse): diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py index 1ea4150..91a34f9 100644 --- a/resend/inboxes/drafts/_inbox_drafts.py +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -148,8 +148,9 @@ class CreateParams(TypedDict): attachments: NotRequired[List["InboxDrafts.AttachmentParams"]] """ Files to store on the draft. Each one needs ``content``, ``filename`` and - ``content_type``. A ``path`` or ``content_id`` returns a 422. A draft can - carry up to 10 files and 25 MB in total. + ``content_type``. A ``path`` or ``content_id`` returns a 422. The files on + a draft can total at most 25 MB. An empty file or a blocked file type + returns a 422. """ thread_id: NotRequired[str] """ @@ -237,9 +238,10 @@ class UpdateParams(TypedDict): attachments: NotRequired[List["InboxDrafts.AttachmentParams"]] """ Files to store on the draft. Each one needs ``content``, ``filename`` and - ``content_type``. A ``path`` or ``content_id`` returns a 422. A draft can - carry up to 10 files and 25 MB in total. Sending the field replaces every - attachment on the draft. Send ``[]`` to remove them all. + ``content_type``. A ``path`` or ``content_id`` returns a 422. The files on + a draft can total at most 25 MB. An empty file or a blocked file type + returns a 422. Sending the field replaces every attachment on the draft. + Send ``[]`` to remove them all. """ class RemoveResponse(BaseResponse): @@ -313,7 +315,10 @@ def create(cls, params: CreateParams) -> CreateResponse: stored on the draft, so each one needs ``content``, ``filename`` and ``content_type``. Only one unsent reply draft can target an email, so a reply draft request for an email that already has one returns the ID of - the existing draft. + the existing draft. That request ignores every other field, except + ``attachments``, which replaces the attachments of the existing draft. A + reply draft created without ``subject`` stores an empty subject and gets + no ``Re:`` prefix, so set one before you send it. see more: https://resend.com/docs/api-reference/inboxes/create-draft Args: @@ -394,8 +399,8 @@ def send(cls, inbox_id: str, draft_id: str) -> SendResponse: """ Send a draft from the inbox address. A reply draft is sent in its thread. A draft without a thread starts a new thread. The draft must have at least - one ``to`` recipient, or the request returns a 422. A draft that was - already sent returns a 409. + one ``to`` recipient, a non-empty ``subject``, and ``html`` or ``text``, + or the request returns a 422. A draft that was already sent returns a 409. see more: https://resend.com/docs/api-reference/inboxes/send-draft Args: @@ -448,7 +453,10 @@ async def create_async(cls, params: CreateParams) -> CreateResponse: stored on the draft, so each one needs ``content``, ``filename`` and ``content_type``. Only one unsent reply draft can target an email, so a reply draft request for an email that already has one returns the ID of - the existing draft. + the existing draft. That request ignores every other field, except + ``attachments``, which replaces the attachments of the existing draft. A + reply draft created without ``subject`` stores an empty subject and gets + no ``Re:`` prefix, so set one before you send it. see more: https://resend.com/docs/api-reference/inboxes/create-draft Args: @@ -529,8 +537,9 @@ async def send_async(cls, inbox_id: str, draft_id: str) -> SendResponse: """ Send a draft from the inbox address (async). A reply draft is sent in its thread. A draft without a thread starts a new thread. The draft must have - at least one ``to`` recipient, or the request returns a 422. A draft that - was already sent returns a 409. + at least one ``to`` recipient, a non-empty ``subject``, and ``html`` or + ``text``, or the request returns a 422. A draft that was already sent + returns a 409. see more: https://resend.com/docs/api-reference/inboxes/send-draft Args: diff --git a/resend/inboxes/threads/_inbox_threads.py b/resend/inboxes/threads/_inbox_threads.py index bfe4acd..40ada33 100644 --- a/resend/inboxes/threads/_inbox_threads.py +++ b/resend/inboxes/threads/_inbox_threads.py @@ -266,6 +266,7 @@ def list(cls, inbox_id: str, params: Optional[ListParams] = None) -> ListRespons """ Retrieve the threads in an inbox, most recently active first. The ``after`` and ``before`` cursors take a thread ID, and only one of them can be passed. + A cursor thread that is not in the selected folders returns a 404. To find threads by text, sender, recipient, attachment, or date, use ``search``. see more: https://resend.com/docs/api-reference/inboxes/list-threads @@ -293,9 +294,12 @@ def search( Find threads by the text of their emails, who sent or received them, whether they have attachments, and their dates. Results are ordered by latest activity, newest first, and paginate like ``list``. Search can be - up to about a minute behind new emails. With a ``query``, ``folders=sent`` - also requires the matching email to be one the inbox sent. When more than - 10,000 threads match, search covers the 10,000 with the newest matching email. + up to about a minute behind new emails. When any email filter (``query``, + ``from``, ``to``, ``cc``, ``bcc``, ``has_attachment``, ``start_date`` or + ``end_date``) is set, the matching email must itself be in one of + ``folders``. For ``sent``, that means an email the inbox sent. When more + than 10,000 threads match, search covers the 10,000 with the newest + matching email. see more: https://resend.com/docs/api-reference/inboxes/search-threads Args: @@ -342,7 +346,9 @@ def update(cls, params: UpdateParams) -> InboxThreadSummary: ``remove_label_ids`` is required. A label ID cannot be both added and removed in the same request. Removing a label from a thread keeps the label itself. Moving a thread out of ``trash`` restores it and clears its - deletion deadline. + deletion deadline. The changes apply in this order: ``read``, ``folder``, + added labels, removed labels. They are not atomic, so a 404 for a label + leaves the earlier changes applied. see more: https://resend.com/docs/api-reference/inboxes/update-thread Args: @@ -385,8 +391,9 @@ async def list_async( """ Retrieve the threads in an inbox, most recently active first (async). The ``after`` and ``before`` cursors take a thread ID, and only one of them - can be passed. To find threads by text, sender, recipient, attachment, or - date, use ``search_async``. + can be passed. A cursor thread that is not in the selected folders returns + a 404. To find threads by text, sender, recipient, attachment, or date, + use ``search_async``. see more: https://resend.com/docs/api-reference/inboxes/list-threads Args: @@ -413,10 +420,12 @@ async def search_async( Find threads by the text of their emails, who sent or received them, whether they have attachments, and their dates (async). Results are ordered by latest activity, newest first, and paginate like ``list``. - Search can be up to about a minute behind new emails. With a ``query``, - ``folders=sent`` also requires the matching email to be one the inbox - sent. When more than 10,000 threads match, search covers the 10,000 with - the newest matching email. + Search can be up to about a minute behind new emails. When any email + filter (``query``, ``from``, ``to``, ``cc``, ``bcc``, ``has_attachment``, + ``start_date`` or ``end_date``) is set, the matching email must itself be + in one of ``folders``. For ``sent``, that means an email the inbox sent. + When more than 10,000 threads match, search covers the 10,000 with the + newest matching email. see more: https://resend.com/docs/api-reference/inboxes/search-threads Args: @@ -463,7 +472,9 @@ async def update_async(cls, params: UpdateParams) -> InboxThreadSummary: or ``remove_label_ids`` is required. A label ID cannot be both added and removed in the same request. Removing a label from a thread keeps the label itself. Moving a thread out of ``trash`` restores it and clears its - deletion deadline. + deletion deadline. The changes apply in this order: ``read``, ``folder``, + added labels, removed labels. They are not atomic, so a 404 for a label + leaves the earlier changes applied. see more: https://resend.com/docs/api-reference/inboxes/update-thread Args: diff --git a/resend/inboxes/threads/emails/_inbox_thread_emails.py b/resend/inboxes/threads/emails/_inbox_thread_emails.py index 68a092f..593d013 100644 --- a/resend/inboxes/threads/emails/_inbox_thread_emails.py +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -131,12 +131,14 @@ class ReplyParams(TypedDict): """ reply_all: NotRequired[bool] """ - Reply to everyone on the original message. The reply goes to the author - of the message, or to its ``Reply-To`` addresses if it has any. The other - ``to`` and ``cc`` recipients of the message are copied in ``cc``. The inbox - address is always excluded. For a message the inbox sent, the reply goes - to that message's ``to`` recipients and copies its ``cc`` recipients. - Defaults to False. + Reply to everyone on the original message. For a message the inbox + received, the reply goes to its ``Reply-To`` addresses, or to its sender + when it has none, and copies the other addresses of its ``To`` header and + its ``cc`` recipients in ``cc``. For a message the inbox sent, the reply + goes to that message's ``Reply-To`` addresses when they are not the inbox + address, otherwise to its ``to`` recipients, and copies its ``cc`` + recipients. The inbox address is always excluded, and duplicates are + removed. Defaults to False. """ attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] """ @@ -246,7 +248,8 @@ def list( ) -> ListResponse: """ Retrieve the messages of a thread, oldest first. You cannot combine - ``after`` and ``before``. + ``after`` and ``before``. A cursor that is not a message of the thread + returns a 404. see more: https://resend.com/docs/api-reference/inboxes/list-thread-emails Args: @@ -324,6 +327,8 @@ def forward(cls, params: ForwardParams) -> ForwardResponse: optional ``html`` or ``text`` note. The attachments of the original message are sent with the forward, together with any ``attachments`` you add. The request fails when they exceed 10 files or 25 MB of ``content`` in total. + An original attachment that was sent from a URL (``path``) cannot be + forwarded, and the request returns a 422. see more: https://resend.com/docs/api-reference/inboxes/forward-thread-email Args: @@ -349,7 +354,8 @@ async def list_async( ) -> ListResponse: """ Retrieve the messages of a thread, oldest first (async). You cannot - combine ``after`` and ``before``. + combine ``after`` and ``before``. A cursor that is not a message of the + thread returns a 404. see more: https://resend.com/docs/api-reference/inboxes/list-thread-emails Args: @@ -429,7 +435,8 @@ async def forward_async(cls, params: ForwardParams) -> ForwardResponse: banner, below the optional ``html`` or ``text`` note. The attachments of the original message are sent with the forward, together with any ``attachments`` you add. The request fails when they exceed 10 files or - 25 MB of ``content`` in total. + 25 MB of ``content`` in total. An original attachment that was sent from a + URL (``path``) cannot be forwarded, and the request returns a 422. see more: https://resend.com/docs/api-reference/inboxes/forward-thread-email Args: diff --git a/resend/webhooks/_webhook_event.py b/resend/webhooks/_webhook_event.py index d5ee24e..fe2b776 100644 --- a/resend/webhooks/_webhook_event.py +++ b/resend/webhooks/_webhook_event.py @@ -446,8 +446,7 @@ class InboxEventEmailAttachment(TypedDict): id: str """ - The ID of the attachment. Not always a UUID. Outbound attachments are - identified by their position in the sent email. + The ID of the attachment. """ filename: Optional[str] """ From cc96a60cf712e4e1d7b08a28e98eab4cf021f42a Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 13:39:46 -0300 Subject: [PATCH 13/20] feat(inboxes): accept an idempotency key on the inbox sending methods Threads.Emails.reply, Threads.Emails.forward, Drafts.create and Drafts.send take an optional options dict with idempotency_key, the same way Emails.send does. The SDK sends it as the Idempotency-Key header. --- resend/inboxes/drafts/_inbox_drafts.py | 76 +++++++++++++++++-- .../threads/emails/_inbox_thread_emails.py | 66 +++++++++++++++- tests/inboxes_async_test.py | 23 ++++++ tests/inboxes_test.py | 31 ++++++++ 4 files changed, 184 insertions(+), 12 deletions(-) diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py index 91a34f9..db89631 100644 --- a/resend/inboxes/drafts/_inbox_drafts.py +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -162,6 +162,27 @@ class CreateParams(TypedDict): emails endpoint. Must be sent with ``thread_id``. """ + class CreateOptions(TypedDict): + """ + CreateOptions is the class that wraps the options for the create method. + + Attributes: + idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + """ + + idempotency_key: NotRequired[str] + """ + Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + Between 1 and 256 characters, so the draft is created only once. A retry with the + same key within 24 hours returns the first response, with the + ``idempotent-replayed: true`` header. Reusing the key with a different + body, or while the first request is still running, returns a 409. + """ + class CreateResponse(BaseResponse): """ CreateResponse is the response of creating a draft. @@ -267,6 +288,27 @@ class RemoveResponse(BaseResponse): Indicates whether the draft was successfully deleted. """ + class SendOptions(TypedDict): + """ + SendOptions is the class that wraps the options for the send method. + + Attributes: + idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + """ + + idempotency_key: NotRequired[str] + """ + Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + Between 1 and 256 characters, so the draft is sent only once. A retry with the + same key within 24 hours returns the first response, with the + ``idempotent-replayed: true`` header. Reusing the key with a different + body, or while the first request is still running, returns a 409. + """ + class SendResponse(BaseResponse): """ SendResponse is the response of sending a draft. @@ -304,7 +346,9 @@ def list(cls, inbox_id: str, params: Optional[ListParams] = None) -> ListRespons return resp @classmethod - def create(cls, params: CreateParams) -> CreateResponse: + def create( + cls, params: CreateParams, options: Optional[CreateOptions] = None + ) -> CreateResponse: """ Create an unsent draft on an inbox. Omit ``thread_id`` and ``reply_to_email_id`` for a draft that starts a new thread. Pass both @@ -323,13 +367,17 @@ def create(cls, params: CreateParams) -> CreateResponse: Args: params (CreateParams): The inbox ID and the content of the draft + options (Optional[CreateOptions]): The create options, ie: idempotency_key Returns: CreateResponse: The object type and the ID of the draft """ path = f"/inboxes/{params['inbox_id']}/drafts" resp = request.Request[InboxDrafts.CreateResponse]( - path=path, params=without_keys(params, "inbox_id"), verb="post" + path=path, + params=without_keys(params, "inbox_id"), + verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp @@ -395,7 +443,9 @@ def remove(cls, inbox_id: str, draft_id: str) -> RemoveResponse: return resp @classmethod - def send(cls, inbox_id: str, draft_id: str) -> SendResponse: + def send( + cls, inbox_id: str, draft_id: str, options: Optional[SendOptions] = None + ) -> SendResponse: """ Send a draft from the inbox address. A reply draft is sent in its thread. A draft without a thread starts a new thread. The draft must have at least @@ -406,13 +456,14 @@ def send(cls, inbox_id: str, draft_id: str) -> SendResponse: Args: inbox_id (str): The Inbox ID. An email address is not accepted. draft_id (str): The Draft ID. + options (Optional[SendOptions]): The send options, ie: idempotency_key Returns: SendResponse: The ID of the sent email """ path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" resp = request.Request[InboxDrafts.SendResponse]( - path=path, params={}, verb="post" + path=path, params={}, verb="post", options=cast(Dict[Any, Any], options) ).perform_with_content() return resp @@ -442,7 +493,9 @@ async def list_async( return resp @classmethod - async def create_async(cls, params: CreateParams) -> CreateResponse: + async def create_async( + cls, params: CreateParams, options: Optional[CreateOptions] = None + ) -> CreateResponse: """ Create an unsent draft on an inbox (async). Omit ``thread_id`` and ``reply_to_email_id`` for a draft that starts a new thread. Pass both @@ -461,13 +514,17 @@ async def create_async(cls, params: CreateParams) -> CreateResponse: Args: params (CreateParams): The inbox ID and the content of the draft + options (Optional[CreateOptions]): The create options, ie: idempotency_key Returns: CreateResponse: The object type and the ID of the draft """ path = f"/inboxes/{params['inbox_id']}/drafts" resp = await AsyncRequest[InboxDrafts.CreateResponse]( - path=path, params=without_keys(params, "inbox_id"), verb="post" + path=path, + params=without_keys(params, "inbox_id"), + verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp @@ -533,7 +590,9 @@ async def remove_async(cls, inbox_id: str, draft_id: str) -> RemoveResponse: return resp @classmethod - async def send_async(cls, inbox_id: str, draft_id: str) -> SendResponse: + async def send_async( + cls, inbox_id: str, draft_id: str, options: Optional[SendOptions] = None + ) -> SendResponse: """ Send a draft from the inbox address (async). A reply draft is sent in its thread. A draft without a thread starts a new thread. The draft must have @@ -545,12 +604,13 @@ async def send_async(cls, inbox_id: str, draft_id: str) -> SendResponse: Args: inbox_id (str): The Inbox ID. An email address is not accepted. draft_id (str): The Draft ID. + options (Optional[SendOptions]): The send options, ie: idempotency_key Returns: SendResponse: The ID of the sent email """ path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" resp = await AsyncRequest[InboxDrafts.SendResponse]( - path=path, params={}, verb="post" + path=path, params={}, verb="post", options=cast(Dict[Any, Any], options) ).perform_with_content() return resp diff --git a/resend/inboxes/threads/emails/_inbox_thread_emails.py b/resend/inboxes/threads/emails/_inbox_thread_emails.py index 593d013..eb6143b 100644 --- a/resend/inboxes/threads/emails/_inbox_thread_emails.py +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -148,6 +148,27 @@ class ReplyParams(TypedDict): fetched when the email is sent. """ + class ReplyOptions(TypedDict): + """ + ReplyOptions is the class that wraps the options for the reply method. + + Attributes: + idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + """ + + idempotency_key: NotRequired[str] + """ + Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + Between 1 and 256 characters, so the reply is sent only once. A retry with the + same key within 24 hours returns the first response, with the + ``idempotent-replayed: true`` header. Reusing the key with a different + body, or while the first request is still running, returns a 409. + """ + class ReplyResponse(BaseResponse): """ ReplyResponse is the response of replying to a thread email. @@ -229,6 +250,27 @@ class ForwardParams(TypedDict): 10 files only, because it is fetched when the email is sent. """ + class ForwardOptions(TypedDict): + """ + ForwardOptions is the class that wraps the options for the forward method. + + Attributes: + idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + """ + + idempotency_key: NotRequired[str] + """ + Unique key that ensures the same operation is not processed multiple times. + Allows for safe retries without duplicating operations. + If provided, will be sent as the `Idempotency-Key` header. + Between 1 and 256 characters, so the forward is sent only once. A retry with the + same key within 24 hours returns the first response, with the + ``idempotent-replayed: true`` header. Reusing the key with a different + body, or while the first request is still running, returns a 409. + """ + class ForwardResponse(BaseResponse): """ ForwardResponse is the response of forwarding a thread email. @@ -291,7 +333,9 @@ def get(cls, inbox_id: str, thread_id: str, email_id: str) -> InboxThreadEmail: return resp @classmethod - def reply(cls, params: ReplyParams) -> ReplyResponse: + def reply( + cls, params: ReplyParams, options: Optional[ReplyOptions] = None + ) -> ReplyResponse: """ Send a reply from the inbox address right away, with no stored draft. The ``to`` recipients come from the message you reply to. For a message @@ -304,6 +348,7 @@ def reply(cls, params: ReplyParams) -> ReplyResponse: Args: params (ReplyParams): The path IDs and the content of the reply + options (Optional[ReplyOptions]): The reply options, ie: idempotency_key Returns: ReplyResponse: The ID of the sent email @@ -316,11 +361,14 @@ def reply(cls, params: ReplyParams) -> ReplyResponse: path=path, params=without_keys(params, "inbox_id", "thread_id", "email_id"), verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp @classmethod - def forward(cls, params: ForwardParams) -> ForwardResponse: + def forward( + cls, params: ForwardParams, options: Optional[ForwardOptions] = None + ) -> ForwardResponse: """ Forward a message from the inbox address right away, with no stored draft. The original message is quoted under a forwarded-message banner, below the @@ -333,6 +381,7 @@ def forward(cls, params: ForwardParams) -> ForwardResponse: Args: params (ForwardParams): The path IDs, the recipients and the note + options (Optional[ForwardOptions]): The forward options, ie: idempotency_key Returns: ForwardResponse: The ID of the sent email @@ -345,6 +394,7 @@ def forward(cls, params: ForwardParams) -> ForwardResponse: path=path, params=without_keys(params, "inbox_id", "thread_id", "email_id"), verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp @@ -399,7 +449,9 @@ async def get_async( return resp @classmethod - async def reply_async(cls, params: ReplyParams) -> ReplyResponse: + async def reply_async( + cls, params: ReplyParams, options: Optional[ReplyOptions] = None + ) -> ReplyResponse: """ Send a reply from the inbox address right away, with no stored draft (async). The ``to`` recipients come from the message you reply to. For a message @@ -412,6 +464,7 @@ async def reply_async(cls, params: ReplyParams) -> ReplyResponse: Args: params (ReplyParams): The path IDs and the content of the reply + options (Optional[ReplyOptions]): The reply options, ie: idempotency_key Returns: ReplyResponse: The ID of the sent email @@ -424,11 +477,14 @@ async def reply_async(cls, params: ReplyParams) -> ReplyResponse: path=path, params=without_keys(params, "inbox_id", "thread_id", "email_id"), verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp @classmethod - async def forward_async(cls, params: ForwardParams) -> ForwardResponse: + async def forward_async( + cls, params: ForwardParams, options: Optional[ForwardOptions] = None + ) -> ForwardResponse: """ Forward a message from the inbox address right away, with no stored draft (async). The original message is quoted under a forwarded-message @@ -441,6 +497,7 @@ async def forward_async(cls, params: ForwardParams) -> ForwardResponse: Args: params (ForwardParams): The path IDs, the recipients and the note + options (Optional[ForwardOptions]): The forward options, ie: idempotency_key Returns: ForwardResponse: The ID of the sent email @@ -453,5 +510,6 @@ async def forward_async(cls, params: ForwardParams) -> ForwardResponse: path=path, params=without_keys(params, "inbox_id", "thread_id", "email_id"), verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp diff --git a/tests/inboxes_async_test.py b/tests/inboxes_async_test.py index 66b6a4e..8f76cde 100644 --- a/tests/inboxes_async_test.py +++ b/tests/inboxes_async_test.py @@ -314,6 +314,29 @@ async def test_inbox_thread_emails_reply_async(self) -> None: ) ) assert reply["id"] == SENT_EMAIL_ID + assert self.mock.call_count == 1 + + async def test_inbox_thread_emails_reply_async_with_options(self) -> None: + self.set_mock_json({"id": SENT_EMAIL_ID}) + options: resend.Inboxes.Threads.Emails.ReplyOptions = {"idempotency_key": "k"} + reply = await resend.Inboxes.Threads.Emails.reply_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "email_id": EMAIL_ID, "text": "Hi"}, + options, + ) + assert reply["id"] == SENT_EMAIL_ID + forward = await resend.Inboxes.Threads.Emails.forward_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "email_id": EMAIL_ID, "to": "a@example.org"}, + {"idempotency_key": "k"}, + ) + assert forward["id"] == SENT_EMAIL_ID + draft = await resend.Inboxes.Drafts.create_async( + {"inbox_id": INBOX_ID, "text": "Hi"}, {"idempotency_key": "k"} + ) + assert draft["id"] == SENT_EMAIL_ID + sent = await resend.Inboxes.Drafts.send_async( + INBOX_ID, DRAFT_ID, {"idempotency_key": "k"} + ) + assert sent["id"] == SENT_EMAIL_ID async def test_should_reply_inbox_thread_emails_async_raise_exception_when_no_content( self, diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index 77ea577..4f50559 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -904,3 +904,34 @@ def test_drafts_paths_and_bodies(self) -> None: resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) req = self.last_request() assert (req["method"], req["url"]) == ("post", f"{base}/{DRAFT_ID}/send") + + def test_sending_methods_send_the_idempotency_key(self) -> None: + resend.Inboxes.Threads.Emails.reply( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "email_id": EMAIL_ID, "text": "Hi"}, + {"idempotency_key": "reply-1"}, + ) + assert self.last_request()["headers"]["Idempotency-Key"] == "reply-1" + + resend.Inboxes.Threads.Emails.forward( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": "colleague@example.org", + }, + {"idempotency_key": "forward-1"}, + ) + assert self.last_request()["headers"]["Idempotency-Key"] == "forward-1" + + resend.Inboxes.Drafts.create( + {"inbox_id": INBOX_ID, "text": "Hi"}, {"idempotency_key": "create-1"} + ) + req = self.last_request() + assert req["headers"]["Idempotency-Key"] == "create-1" + assert req["json"] == {"text": "Hi"} + + resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID, {"idempotency_key": "send-1"}) + assert self.last_request()["headers"]["Idempotency-Key"] == "send-1" + + resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) + assert "Idempotency-Key" not in self.last_request()["headers"] From 81201d9dc40a3959fe8ad61ac1108859ecca33b8 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 13:39:46 -0300 Subject: [PATCH 14/20] docs(examples): send the example draft with an idempotency key --- examples/inboxes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/inboxes.py b/examples/inboxes.py index c83bf63..aeab831 100644 --- a/examples/inboxes.py +++ b/examples/inboxes.py @@ -140,8 +140,11 @@ drafts: resend.Inboxes.Drafts.ListResponse = resend.Inboxes.Drafts.list(inbox_id) print(f"Found {len(drafts['data'])} drafts") +send_options: resend.Inboxes.Drafts.SendOptions = { + "idempotency_key": f"send-draft-{draft['id']}", +} sent: resend.Inboxes.Drafts.SendResponse = resend.Inboxes.Drafts.send( - inbox_id, draft["id"] + inbox_id, draft["id"], send_options ) print(f"Sent draft as email: {sent['id']}") From 4af433a0bff81083b0302a8cdd877c652d033572 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 14:13:32 -0300 Subject: [PATCH 15/20] feat(inboxes): accept custom headers on reply, forward and send draft Threads.Emails.reply and forward take headers in their params, as Emails.send does. Drafts.send takes an optional params dict with headers, between the path IDs and the options. Resend sets In-Reply-To, References and Reply-To, so the API rejects them. --- resend/inboxes/drafts/_inbox_drafts.py | 42 ++++++++++++++++--- .../threads/emails/_inbox_thread_emails.py | 14 +++++++ tests/inboxes_async_test.py | 5 ++- tests/inboxes_test.py | 39 ++++++++++++++++- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py index db89631..b34705b 100644 --- a/resend/inboxes/drafts/_inbox_drafts.py +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -288,6 +288,21 @@ class RemoveResponse(BaseResponse): Indicates whether the draft was successfully deleted. """ + class SendParams(TypedDict): + """ + SendParams are the optional body parameters for sending a draft. + + Attributes: + headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email. + """ + + headers: NotRequired[Dict[str, str]] + """ + Custom headers to be added to the email. The ``In-Reply-To``, + ``References`` and ``Reply-To`` headers are set by Resend and cannot be + set here. Sending one of them, in any letter case, returns a 422. + """ + class SendOptions(TypedDict): """ SendOptions is the class that wraps the options for the send method. @@ -444,18 +459,24 @@ def remove(cls, inbox_id: str, draft_id: str) -> RemoveResponse: @classmethod def send( - cls, inbox_id: str, draft_id: str, options: Optional[SendOptions] = None + cls, + inbox_id: str, + draft_id: str, + params: Optional[SendParams] = None, + options: Optional[SendOptions] = None, ) -> SendResponse: """ Send a draft from the inbox address. A reply draft is sent in its thread. A draft without a thread starts a new thread. The draft must have at least one ``to`` recipient, a non-empty ``subject``, and ``html`` or ``text``, or the request returns a 422. A draft that was already sent returns a 409. + The request body is optional and takes only ``headers``. see more: https://resend.com/docs/api-reference/inboxes/send-draft Args: inbox_id (str): The Inbox ID. An email address is not accepted. draft_id (str): The Draft ID. + params (Optional[SendParams]): Optional body parameters, ie: headers options (Optional[SendOptions]): The send options, ie: idempotency_key Returns: @@ -463,7 +484,10 @@ def send( """ path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" resp = request.Request[InboxDrafts.SendResponse]( - path=path, params={}, verb="post", options=cast(Dict[Any, Any], options) + path=path, + params=cast(Dict[Any, Any], params) if params else {}, + verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp @@ -591,19 +615,24 @@ async def remove_async(cls, inbox_id: str, draft_id: str) -> RemoveResponse: @classmethod async def send_async( - cls, inbox_id: str, draft_id: str, options: Optional[SendOptions] = None + cls, + inbox_id: str, + draft_id: str, + params: Optional[SendParams] = None, + options: Optional[SendOptions] = None, ) -> SendResponse: """ Send a draft from the inbox address (async). A reply draft is sent in its thread. A draft without a thread starts a new thread. The draft must have at least one ``to`` recipient, a non-empty ``subject``, and ``html`` or ``text``, or the request returns a 422. A draft that was already sent - returns a 409. + returns a 409. The request body is optional and takes only ``headers``. see more: https://resend.com/docs/api-reference/inboxes/send-draft Args: inbox_id (str): The Inbox ID. An email address is not accepted. draft_id (str): The Draft ID. + params (Optional[SendParams]): Optional body parameters, ie: headers options (Optional[SendOptions]): The send options, ie: idempotency_key Returns: @@ -611,6 +640,9 @@ async def send_async( """ path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" resp = await AsyncRequest[InboxDrafts.SendResponse]( - path=path, params={}, verb="post", options=cast(Dict[Any, Any], options) + path=path, + params=cast(Dict[Any, Any], params) if params else {}, + verb="post", + options=cast(Dict[Any, Any], options), ).perform_with_content() return resp diff --git a/resend/inboxes/threads/emails/_inbox_thread_emails.py b/resend/inboxes/threads/emails/_inbox_thread_emails.py index eb6143b..c73c74b 100644 --- a/resend/inboxes/threads/emails/_inbox_thread_emails.py +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -86,6 +86,7 @@ class ReplyParams(TypedDict): bcc (NotRequired[Union[str, List[str]]]): BCC recipients. subject (NotRequired[str]): The subject of the reply. reply_all (NotRequired[bool]): Reply to everyone on the original message. + headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email. attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): Files to attach. """ @@ -140,6 +141,12 @@ class ReplyParams(TypedDict): recipients. The inbox address is always excluded, and duplicates are removed. Defaults to False. """ + headers: NotRequired[Dict[str, str]] + """ + Custom headers to be added to the email. The ``In-Reply-To``, + ``References`` and ``Reply-To`` headers are set by Resend and cannot be + set here. Sending one of them, in any letter case, returns a 422. + """ attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] """ Files to attach, as on ``Emails.send``. Each one needs ``content`` or @@ -196,6 +203,7 @@ class ForwardParams(TypedDict): html (NotRequired[str]): An HTML note sent above the quoted original. text (NotRequired[str]): A plain-text note sent above the quoted original. subject (NotRequired[str]): The subject of the forward. + headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email. attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): Files to attach. """ @@ -241,6 +249,12 @@ class ForwardParams(TypedDict): The subject of the forward. Cannot contain a line break. When omitted, the thread subject is used, prefixed with ``Fwd:`` if it does not already start with it. """ + headers: NotRequired[Dict[str, str]] + """ + Custom headers to be added to the email. The ``In-Reply-To``, + ``References`` and ``Reply-To`` headers are set by Resend and cannot be + set here. Sending one of them, in any letter case, returns a 422. + """ attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] """ Files to attach, as on ``Emails.send``. Each one needs ``content`` or diff --git a/tests/inboxes_async_test.py b/tests/inboxes_async_test.py index 8f76cde..e3823a6 100644 --- a/tests/inboxes_async_test.py +++ b/tests/inboxes_async_test.py @@ -334,7 +334,10 @@ async def test_inbox_thread_emails_reply_async_with_options(self) -> None: ) assert draft["id"] == SENT_EMAIL_ID sent = await resend.Inboxes.Drafts.send_async( - INBOX_ID, DRAFT_ID, {"idempotency_key": "k"} + INBOX_ID, + DRAFT_ID, + {"headers": {"X-Ticket-Id": "4821"}}, + {"idempotency_key": "k"}, ) assert sent["id"] == SENT_EMAIL_ID diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index 4f50559..8100f18 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -930,8 +930,45 @@ def test_sending_methods_send_the_idempotency_key(self) -> None: assert req["headers"]["Idempotency-Key"] == "create-1" assert req["json"] == {"text": "Hi"} - resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID, {"idempotency_key": "send-1"}) + resend.Inboxes.Drafts.send( + INBOX_ID, DRAFT_ID, options={"idempotency_key": "send-1"} + ) assert self.last_request()["headers"]["Idempotency-Key"] == "send-1" resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) assert "Idempotency-Key" not in self.last_request()["headers"] + + def test_sending_methods_send_custom_headers(self) -> None: + headers = {"X-Ticket-Id": "4821"} + resend.Inboxes.Threads.Emails.reply( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "text": "Hi", + "headers": headers, + } + ) + assert self.last_request()["json"] == {"text": "Hi", "headers": headers} + + resend.Inboxes.Threads.Emails.forward( + { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "to": "colleague@example.org", + "headers": headers, + } + ) + assert self.last_request()["json"] == { + "to": "colleague@example.org", + "headers": headers, + } + + resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID, {"headers": headers}) + req = self.last_request() + assert req["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}/drafts/{DRAFT_ID}/send" + assert req["json"] == {"headers": headers} + + resend.Inboxes.Drafts.send(INBOX_ID, DRAFT_ID) + assert self.last_request()["json"] == {} From fc2f1f78c2a2da796059e37984060e157c954e21 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 14:13:32 -0300 Subject: [PATCH 16/20] docs(examples): send the example draft with a custom header --- examples/inboxes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/inboxes.py b/examples/inboxes.py index aeab831..9fef4ba 100644 --- a/examples/inboxes.py +++ b/examples/inboxes.py @@ -143,8 +143,11 @@ send_options: resend.Inboxes.Drafts.SendOptions = { "idempotency_key": f"send-draft-{draft['id']}", } +send_params: resend.Inboxes.Drafts.SendParams = { + "headers": {"X-Ticket-Id": "4821"}, +} sent: resend.Inboxes.Drafts.SendResponse = resend.Inboxes.Drafts.send( - inbox_id, draft["id"], send_options + inbox_id, draft["id"], send_params, send_options ) print(f"Sent draft as email: {sent['id']}") From 4000679aa16a829df46786912fc5aa371662014d Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Tue, 13 Oct 2026 15:00:52 -0300 Subject: [PATCH 17/20] docs(inboxes): align the doc comments with the spec review An idempotent retry replays the first successful response, and a failed request can be retried with the same key. Add the rules for custom header names and values and for reply and forward attachments. Drafts.update accepts None for attachments, which removes them all. --- resend/inboxes/drafts/_inbox_drafts.py | 26 +++++++++++-------- .../threads/emails/_inbox_thread_emails.py | 26 +++++++++++++------ tests/inboxes_test.py | 5 ++++ 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py index b34705b..0a17c3d 100644 --- a/resend/inboxes/drafts/_inbox_drafts.py +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -34,7 +34,7 @@ class AttachmentParams(TypedDict): content: str """ - The content of the file, encoded in base64. + The content of the file, encoded in base64. It cannot be empty. """ filename: str """ @@ -178,9 +178,10 @@ class CreateOptions(TypedDict): Allows for safe retries without duplicating operations. If provided, will be sent as the `Idempotency-Key` header. Between 1 and 256 characters, so the draft is created only once. A retry with the - same key within 24 hours returns the first response, with the - ``idempotent-replayed: true`` header. Reusing the key with a different - body, or while the first request is still running, returns a 409. + same key within 24 hours replays the first successful (2xx) response, + with the ``idempotent-replayed: true`` header. A failed request is not + stored, so you can retry it with the same key. Reusing the key with a + different body, or while the first request is still running, returns a 409. """ class CreateResponse(BaseResponse): @@ -218,7 +219,7 @@ class UpdateParams(TypedDict): subject (NotRequired[Optional[str]]): The subject. html (NotRequired[Optional[str]]): The HTML body. text (NotRequired[Optional[str]]): The plain text body. - attachments (NotRequired[List[AttachmentParams]]): Files that replace + attachments (NotRequired[Optional[List[AttachmentParams]]]): Files that replace every attachment on the draft. """ @@ -256,13 +257,13 @@ class UpdateParams(TypedDict): """ The plain text body. Pass None to clear. """ - attachments: NotRequired[List["InboxDrafts.AttachmentParams"]] + attachments: NotRequired[Optional[List["InboxDrafts.AttachmentParams"]]] """ Files to store on the draft. Each one needs ``content``, ``filename`` and ``content_type``. A ``path`` or ``content_id`` returns a 422. The files on a draft can total at most 25 MB. An empty file or a blocked file type returns a 422. Sending the field replaces every attachment on the draft. - Send ``[]`` to remove them all. + Send ``[]`` or None to remove them all. """ class RemoveResponse(BaseResponse): @@ -300,7 +301,9 @@ class SendParams(TypedDict): """ Custom headers to be added to the email. The ``In-Reply-To``, ``References`` and ``Reply-To`` headers are set by Resend and cannot be - set here. Sending one of them, in any letter case, returns a 422. + set here. Sending one of them, in any letter case, returns a 422. Header + names cannot be empty, and names and values cannot contain line breaks + or null characters, or the request returns a 422. """ class SendOptions(TypedDict): @@ -319,9 +322,10 @@ class SendOptions(TypedDict): Allows for safe retries without duplicating operations. If provided, will be sent as the `Idempotency-Key` header. Between 1 and 256 characters, so the draft is sent only once. A retry with the - same key within 24 hours returns the first response, with the - ``idempotent-replayed: true`` header. Reusing the key with a different - body, or while the first request is still running, returns a 409. + same key within 24 hours replays the first successful (2xx) response, + with the ``idempotent-replayed: true`` header. A failed request is not + stored, so you can retry it with the same key. Reusing the key with a + different body, or while the first request is still running, returns a 409. """ class SendResponse(BaseResponse): diff --git a/resend/inboxes/threads/emails/_inbox_thread_emails.py b/resend/inboxes/threads/emails/_inbox_thread_emails.py index c73c74b..2d943a5 100644 --- a/resend/inboxes/threads/emails/_inbox_thread_emails.py +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -145,7 +145,9 @@ class ReplyParams(TypedDict): """ Custom headers to be added to the email. The ``In-Reply-To``, ``References`` and ``Reply-To`` headers are set by Resend and cannot be - set here. Sending one of them, in any letter case, returns a 422. + set here. Sending one of them, in any letter case, returns a 422. Header + names cannot be empty, and names and values cannot contain line breaks + or null characters, or the request returns a 422. """ attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] """ @@ -153,6 +155,8 @@ class ReplyParams(TypedDict): ``path``. Up to 10 files. The ``content`` of all files can total at most 25 MB. A ``path`` file counts toward the 10 files only, because it is fetched when the email is sent. + An empty file, a blocked file type, or a ``path`` that is not an ``http`` + or ``https`` URL or points to ``localhost`` returns a 422. """ class ReplyOptions(TypedDict): @@ -171,9 +175,10 @@ class ReplyOptions(TypedDict): Allows for safe retries without duplicating operations. If provided, will be sent as the `Idempotency-Key` header. Between 1 and 256 characters, so the reply is sent only once. A retry with the - same key within 24 hours returns the first response, with the - ``idempotent-replayed: true`` header. Reusing the key with a different - body, or while the first request is still running, returns a 409. + same key within 24 hours replays the first successful (2xx) response, + with the ``idempotent-replayed: true`` header. A failed request is not + stored, so you can retry it with the same key. Reusing the key with a + different body, or while the first request is still running, returns a 409. """ class ReplyResponse(BaseResponse): @@ -253,7 +258,9 @@ class ForwardParams(TypedDict): """ Custom headers to be added to the email. The ``In-Reply-To``, ``References`` and ``Reply-To`` headers are set by Resend and cannot be - set here. Sending one of them, in any letter case, returns a 422. + set here. Sending one of them, in any letter case, returns a 422. Header + names cannot be empty, and names and values cannot contain line breaks + or null characters, or the request returns a 422. """ attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] """ @@ -262,6 +269,8 @@ class ForwardParams(TypedDict): message, which the forward also sends. Together they can have up to 10 files and at most 25 MB of ``content``. A ``path`` file counts toward the 10 files only, because it is fetched when the email is sent. + An empty file, a blocked file type, or a ``path`` that is not an ``http`` + or ``https`` URL or points to ``localhost`` returns a 422. """ class ForwardOptions(TypedDict): @@ -280,9 +289,10 @@ class ForwardOptions(TypedDict): Allows for safe retries without duplicating operations. If provided, will be sent as the `Idempotency-Key` header. Between 1 and 256 characters, so the forward is sent only once. A retry with the - same key within 24 hours returns the first response, with the - ``idempotent-replayed: true`` header. Reusing the key with a different - body, or while the first request is still running, returns a 409. + same key within 24 hours replays the first successful (2xx) response, + with the ``idempotent-replayed: true`` header. A failed request is not + stored, so you can retry it with the same key. Reusing the key with a + different body, or while the first request is still running, returns a 409. """ class ForwardResponse(BaseResponse): diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index 8100f18..6a1e047 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -897,6 +897,11 @@ def test_drafts_paths_and_bodies(self) -> None: ) assert self.last_request()["json"] == {"attachments": []} + resend.Inboxes.Drafts.update( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "attachments": None} + ) + assert self.last_request()["json"] == {"attachments": None} + resend.Inboxes.Drafts.remove(INBOX_ID, DRAFT_ID) req = self.last_request() assert (req["method"], req["url"]) == ("delete", f"{base}/{DRAFT_ID}") From e20cf433790c93f1809587dbc6d464da0f1070aa Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 14 Oct 2026 09:27:02 -0300 Subject: [PATCH 18/20] fix(inboxes): rename message_count to email_count --- resend/inboxes/threads/_inbox_thread.py | 2 +- tests/inboxes_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resend/inboxes/threads/_inbox_thread.py b/resend/inboxes/threads/_inbox_thread.py index d6fbd6d..22c67b7 100644 --- a/resend/inboxes/threads/_inbox_thread.py +++ b/resend/inboxes/threads/_inbox_thread.py @@ -65,7 +65,7 @@ class InboxThread(_InboxThreadFrom): """ The labels attached to the thread. """ - message_count: int + email_count: int """ The number of emails in the thread. """ diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index 6a1e047..0156b93 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -40,7 +40,7 @@ "cc": [], "bcc": [], "labels": [THREAD_LABEL], - "message_count": 2, + "email_count": 2, "has_attachment": True, "has_draft": False, "read": False, From 877bdbbe1090110abedc9b571ae89092d9e37177 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 14 Oct 2026 12:56:28 -0300 Subject: [PATCH 19/20] docs(inboxes): describe the agent tone presets --- resend/inboxes/agent/_inbox_agent.py | 9 ++++++--- resend/inboxes/agent/_inbox_agent_settings.py | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/resend/inboxes/agent/_inbox_agent.py b/resend/inboxes/agent/_inbox_agent.py index 72b5d23..adb283f 100644 --- a/resend/inboxes/agent/_inbox_agent.py +++ b/resend/inboxes/agent/_inbox_agent.py @@ -45,9 +45,12 @@ class UpdateParams(TypedDict): """ tone: NotRequired[Optional[str]] """ - The tone the agent writes in, such as ``friendly and concise``. - Max 64 characters. Leading and trailing whitespace is trimmed, and the - trimmed value must not be empty. Send None to clear the tone. + The tone the agent writes in. Use one of the presets + ``concise_professional``, ``concise_casual``, ``expanded_professional``, + or ``expanded_casual``, or describe the tone in your own words, such as + ``friendly and concise``. Custom text is max 64 characters. Leading and + trailing whitespace is trimmed, and the trimmed value must not be empty. + Send None to clear the tone. """ enabled_actions: NotRequired[List[InboxAgentAction]] """ diff --git a/resend/inboxes/agent/_inbox_agent_settings.py b/resend/inboxes/agent/_inbox_agent_settings.py index 72b746c..2191f35 100644 --- a/resend/inboxes/agent/_inbox_agent_settings.py +++ b/resend/inboxes/agent/_inbox_agent_settings.py @@ -34,7 +34,10 @@ class InboxAgentSettings(BaseResponse): """ tone: Optional[str] """ - The tone the agent writes in, such as ``friendly and concise``. + The tone the agent writes in. One of the presets ``concise_professional``, + ``concise_casual``, ``expanded_professional``, or ``expanded_casual``, or + custom text in the owner's own words, such as ``friendly and concise``. + Older dashboard values such as ``Concise · Casual`` are custom text. None when no tone is set. """ enabled_actions: List[InboxAgentAction] From 4803c1709c03ea2ce3b0492b8aad8742f1332e07 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 16 Oct 2026 15:47:08 -0300 Subject: [PATCH 20/20] feat(inboxes): match the stabilized Inboxes API Follow resend-openapi #137 and resend-node 7943103: - Every inbox route takes the Inbox ID or the inbox email address. - Agent: `enabled_actions` is now `actions`, `archive_thread` and `delete_thread` are now `archive` and `move_to_trash`, and `tone` is one of the four presets. - Add `Inboxes.Threads.Labels` with `add`, `set` and `remove`. Thread update no longer takes `add_label_ids` or `remove_label_ids`. - Update draft returns only `object` and `id`. - Search results lose `highlights`. - Inboxes add `updated_at`, and list items add `created_at` and `updated_at`. - Reply and forward no longer cap attachments at 10 files. Co-Authored-By: Claude Opus 5.5 --- examples/async/inboxes_async.py | 4 +- examples/inboxes.py | 25 +- resend/__init__.py | 11 +- resend/inboxes/__init__.py | 9 +- resend/inboxes/_inbox.py | 12 + resend/inboxes/_inboxes.py | 23 +- resend/inboxes/_query.py | 9 + resend/inboxes/agent/_inbox_agent.py | 42 ++-- resend/inboxes/agent/_inbox_agent_settings.py | 24 +- resend/inboxes/drafts/_inbox_drafts.py | 81 ++++--- resend/inboxes/labels/_inbox_labels.py | 34 +-- resend/inboxes/threads/_inbox_thread.py | 33 +-- resend/inboxes/threads/_inbox_threads.py | 89 +++---- .../threads/emails/_inbox_thread_emails.py | 55 ++--- resend/inboxes/threads/labels/__init__.py | 0 .../threads/labels/_inbox_thread_labels.py | 226 ++++++++++++++++++ tests/inboxes_async_test.py | 78 +++++- tests/inboxes_test.py | 225 ++++++++++++++--- 18 files changed, 724 insertions(+), 256 deletions(-) create mode 100644 resend/inboxes/threads/labels/__init__.py create mode 100644 resend/inboxes/threads/labels/_inbox_thread_labels.py diff --git a/examples/async/inboxes_async.py b/examples/async/inboxes_async.py index 586c254..efcd85a 100644 --- a/examples/async/inboxes_async.py +++ b/examples/async/inboxes_async.py @@ -32,8 +32,8 @@ async def main() -> None: if threads["data"]: thread_id = threads["data"][0]["id"] - await resend.Inboxes.Threads.update_async( - {"inbox_id": inbox_id, "id": thread_id, "add_label_ids": [label["id"]]} + await resend.Inboxes.Threads.Labels.add_async( + {"inbox_id": inbox_id, "thread_id": thread_id, "labels": [label["id"]]} ) emails: resend.Inboxes.Threads.Emails.ListResponse = ( await resend.Inboxes.Threads.Emails.list_async(inbox_id, thread_id) diff --git a/examples/inboxes.py b/examples/inboxes.py index 9fef4ba..ef7abc1 100644 --- a/examples/inboxes.py +++ b/examples/inboxes.py @@ -24,11 +24,11 @@ resend.Inboxes.update(update_params) agent: resend.InboxAgentSettings = resend.Inboxes.Agent.get(inbox_id) -print(f"Agent actions: {agent['enabled_actions']}") +print(f"Agent actions: {agent['actions']}") agent_params: resend.Inboxes.Agent.UpdateParams = { "inbox_id": inbox_id, - "tone": "friendly and concise", - "enabled_actions": ["draft_reply", "add_labels"], + "tone": "concise_casual", + "actions": ["draft_reply", "add_labels"], } resend.Inboxes.Agent.update(agent_params) @@ -62,7 +62,7 @@ inbox_id, search_params ) for result in results["data"]: - print(f"{result['subject']}: {result['highlights']}") + print(f"{result['subject']}: matched email {result['matched_email_id']}") if threads["data"]: thread_id = threads["data"][0]["id"] @@ -73,10 +73,20 @@ "inbox_id": inbox_id, "id": thread_id, "read": True, - "add_label_ids": [label["id"]], } resend.Inboxes.Threads.update(thread_update) + add_labels: resend.Inboxes.Threads.Labels.AddParams = { + "inbox_id": inbox_id, + "thread_id": thread_id, + "labels": [label["id"]], + } + thread_labels: resend.Inboxes.Threads.Labels.ListResponse = ( + resend.Inboxes.Threads.Labels.add(add_labels) + ) + print(f"Thread has {len(thread_labels['data'])} labels") + resend.Inboxes.Threads.Labels.remove(inbox_id, thread_id, label["id"]) + emails: resend.Inboxes.Threads.Emails.ListResponse = ( resend.Inboxes.Threads.Emails.list(inbox_id, thread_id) ) @@ -133,7 +143,10 @@ "id": draft["id"], "html": "

Refund issued for order 1041.

", } -updated_draft: resend.InboxDraft = resend.Inboxes.Drafts.update(draft_update) +updated: resend.Inboxes.Drafts.UpdateResponse = resend.Inboxes.Drafts.update( + draft_update +) +updated_draft: resend.InboxDraft = resend.Inboxes.Drafts.get(inbox_id, updated["id"]) print(f"Draft {updated_draft['id']} is a {updated_draft['type']} draft") print(f"Draft attachments: {[a['filename'] for a in updated_draft['attachments']]}") diff --git a/resend/__init__.py b/resend/__init__.py index e234095..6e5aa88 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -58,20 +58,21 @@ from .inboxes._inboxes import Inboxes from .inboxes.agent._inbox_agent import InboxAgent from .inboxes.agent._inbox_agent_settings import (InboxAgentAction, - InboxAgentSettings) + InboxAgentSettings, + InboxAgentTone) from .inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftAttachment, InboxDraftListItem, InboxDraftType) from .inboxes.drafts._inbox_drafts import InboxDrafts from .inboxes.labels._inbox_label import InboxLabel from .inboxes.labels._inbox_labels import InboxLabels from .inboxes.threads._inbox_thread import (InboxThread, InboxThreadLabel, - InboxThreadSearchHighlights, InboxThreadSearchResult, InboxThreadSummary) from .inboxes.threads._inbox_threads import InboxThreads from .inboxes.threads.emails._inbox_thread_email import ( InboxThreadEmail, InboxThreadEmailAttachment) from .inboxes.threads.emails._inbox_thread_emails import InboxThreadEmails +from .inboxes.threads.labels._inbox_thread_labels import InboxThreadLabels from .logs._log import Log from .logs._logs import Logs from .oauth_grants._oauth_grant import OAuthGrant, OAuthGrantClient @@ -114,8 +115,7 @@ InboxDeletedEventData, InboxDraftCreatedEvent, InboxDraftDeletedEvent, - InboxDraftEventData, - InboxDraftSentEvent, + InboxDraftEventData, InboxDraftSentEvent, InboxDraftSentEventData, InboxDraftUpdatedEvent, InboxEmailReceivedEvent, @@ -189,6 +189,7 @@ "InboxLabels", "InboxThreads", "InboxThreadEmails", + "InboxThreadLabels", "InboxDrafts", # Types "Audience", @@ -311,10 +312,10 @@ "MoveThreadFolder", "InboxAgentAction", "InboxAgentSettings", + "InboxAgentTone", "InboxLabel", "InboxThread", "InboxThreadLabel", - "InboxThreadSearchHighlights", "InboxThreadSearchResult", "InboxThreadSummary", "InboxThreadEmail", diff --git a/resend/inboxes/__init__.py b/resend/inboxes/__init__.py index 0461ffa..3c9da8e 100644 --- a/resend/inboxes/__init__.py +++ b/resend/inboxes/__init__.py @@ -3,7 +3,8 @@ from resend.inboxes._inboxes import Inboxes from resend.inboxes.agent._inbox_agent import InboxAgent from resend.inboxes.agent._inbox_agent_settings import (InboxAgentAction, - InboxAgentSettings) + InboxAgentSettings, + InboxAgentTone) from resend.inboxes.drafts._inbox_draft import (InboxDraft, InboxDraftAttachment, InboxDraftListItem, @@ -13,7 +14,6 @@ from resend.inboxes.labels._inbox_labels import InboxLabels from resend.inboxes.threads._inbox_thread import (InboxThread, InboxThreadLabel, - InboxThreadSearchHighlights, InboxThreadSearchResult, InboxThreadSummary) from resend.inboxes.threads._inbox_threads import InboxThreads @@ -21,6 +21,8 @@ InboxThreadEmail, InboxThreadEmailAttachment) from resend.inboxes.threads.emails._inbox_thread_emails import \ InboxThreadEmails +from resend.inboxes.threads.labels._inbox_thread_labels import \ + InboxThreadLabels __all__ = [ "Inboxes", @@ -28,6 +30,7 @@ "InboxLabels", "InboxThreads", "InboxThreadEmails", + "InboxThreadLabels", "InboxDrafts", "Inbox", "InboxListItem", @@ -35,10 +38,10 @@ "MoveThreadFolder", "InboxAgentAction", "InboxAgentSettings", + "InboxAgentTone", "InboxLabel", "InboxThread", "InboxThreadLabel", - "InboxThreadSearchHighlights", "InboxThreadSearchResult", "InboxThreadSummary", "InboxThreadEmail", diff --git a/resend/inboxes/_inbox.py b/resend/inboxes/_inbox.py index f82c90b..126ed4b 100644 --- a/resend/inboxes/_inbox.py +++ b/resend/inboxes/_inbox.py @@ -70,6 +70,10 @@ class Inbox(BaseResponse): """ ISO 8601 timestamp when the inbox was created. """ + updated_at: str + """ + ISO 8601 timestamp when the inbox was last updated. + """ class InboxListItem(TypedDict): @@ -103,3 +107,11 @@ class InboxListItem(TypedDict): ISO 8601 timestamp of the newest received email in the Inbox folder. None when the folder has no received email. """ + created_at: str + """ + ISO 8601 timestamp when the inbox was created. + """ + updated_at: str + """ + ISO 8601 timestamp when the inbox was last updated. + """ diff --git a/resend/inboxes/_inboxes.py b/resend/inboxes/_inboxes.py index 39dc0d0..cd3a470 100644 --- a/resend/inboxes/_inboxes.py +++ b/resend/inboxes/_inboxes.py @@ -1,12 +1,11 @@ from typing import Any, Dict, List, Optional, cast -from urllib.parse import quote from typing_extensions import NotRequired, TypedDict from resend import request from resend._base_response import BaseResponse from resend.inboxes._inbox import Inbox, InboxListItem -from resend.inboxes._query import without_keys +from resend.inboxes._query import inbox_path, without_keys from resend.pagination_helper import PaginationHelper from .agent._inbox_agent import InboxAgent @@ -151,14 +150,14 @@ class UpdateParams(TypedDict): Provide at least one of ``name`` or ``from_name``. Attributes: - id (str): The Inbox ID. An email address is not accepted. + id (str): The Inbox ID or the inbox email address. name (NotRequired[str]): New internal name for the inbox. from_name (NotRequired[str]): The name recipients see when mail is sent from this inbox. """ id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ name: NotRequired[str] """ @@ -279,7 +278,7 @@ def get(cls, inbox_id: str) -> Inbox: Returns: Inbox: The inbox object """ - path = f"/inboxes/{quote(inbox_id, safe='')}" + path = inbox_path(inbox_id) resp = request.Request[Inbox]( path=path, params={}, verb="get" ).perform_with_content() @@ -298,7 +297,7 @@ def update(cls, params: UpdateParams) -> UpdateResponse: Returns: UpdateResponse: The object type and the ID of the inbox """ - path = f"/inboxes/{params['id']}" + path = inbox_path(params["id"]) resp = request.Request[Inboxes.UpdateResponse]( path=path, params=without_keys(params, "id"), verb="patch" ).perform_with_content() @@ -312,12 +311,12 @@ def remove(cls, inbox_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-inbox Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. Returns: RemoveResponse: The inbox ID and its deleted status """ - path = f"/inboxes/{inbox_id}" + path = inbox_path(inbox_id) resp = request.Request[Inboxes.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() @@ -387,7 +386,7 @@ async def get_async(cls, inbox_id: str) -> Inbox: Returns: Inbox: The inbox object """ - path = f"/inboxes/{quote(inbox_id, safe='')}" + path = inbox_path(inbox_id) resp = await AsyncRequest[Inbox]( path=path, params={}, verb="get" ).perform_with_content() @@ -406,7 +405,7 @@ async def update_async(cls, params: UpdateParams) -> UpdateResponse: Returns: UpdateResponse: The object type and the ID of the inbox """ - path = f"/inboxes/{params['id']}" + path = inbox_path(params["id"]) resp = await AsyncRequest[Inboxes.UpdateResponse]( path=path, params=without_keys(params, "id"), verb="patch" ).perform_with_content() @@ -420,12 +419,12 @@ async def remove_async(cls, inbox_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-inbox Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. Returns: RemoveResponse: The inbox ID and its deleted status """ - path = f"/inboxes/{inbox_id}" + path = inbox_path(inbox_id) resp = await AsyncRequest[Inboxes.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() diff --git a/resend/inboxes/_query.py b/resend/inboxes/_query.py index 4e19626..edd2bf2 100644 --- a/resend/inboxes/_query.py +++ b/resend/inboxes/_query.py @@ -1,4 +1,5 @@ from typing import Any, Dict, Mapping, Optional +from urllib.parse import quote from resend.pagination_helper import PaginationHelper @@ -40,3 +41,11 @@ def without_keys(params: Mapping[str, Any], *keys: str) -> Dict[str, Any]: path IDs in the params dict, but the API takes them in the path only. """ return {k: v for k, v in params.items() if k not in keys} + + +def inbox_path(inbox_id: str) -> str: + """ + Return the path of an inbox. The API takes the Inbox ID or the inbox email + address, and an address must be percent-encoded to fit in the path. + """ + return f"/inboxes/{quote(inbox_id, safe='')}" diff --git a/resend/inboxes/agent/_inbox_agent.py b/resend/inboxes/agent/_inbox_agent.py index adb283f..4c5193b 100644 --- a/resend/inboxes/agent/_inbox_agent.py +++ b/resend/inboxes/agent/_inbox_agent.py @@ -4,9 +4,10 @@ from resend import request from resend._base_response import BaseResponse -from resend.inboxes._query import without_keys +from resend.inboxes._query import inbox_path, without_keys -from ._inbox_agent_settings import InboxAgentAction, InboxAgentSettings +from ._inbox_agent_settings import (InboxAgentAction, InboxAgentSettings, + InboxAgentTone) # Async imports (optional - only available with pip install resend[async]) try: @@ -23,19 +24,19 @@ class InboxAgent: class UpdateParams(TypedDict): """ UpdateParams are the parameters for updating the agent settings of an inbox. - At least one of ``instructions``, ``tone``, or ``enabled_actions`` must be + At least one of ``instructions``, ``tone``, or ``actions`` must be provided. Omitted fields keep their current value. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. instructions (NotRequired[Optional[str]]): Instructions for the agent. - tone (NotRequired[Optional[str]]): The tone the agent writes in. - enabled_actions (NotRequired[List[InboxAgentAction]]): The actions the agent can take. + tone (NotRequired[Optional[InboxAgentTone]]): The tone the agent writes in. + actions (NotRequired[List[InboxAgentAction]]): The actions the agent can take. """ inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ instructions: NotRequired[Optional[str]] """ @@ -43,16 +44,11 @@ class UpdateParams(TypedDict): Max 4000 characters. Leading and trailing whitespace is trimmed, and the trimmed value must not be empty. Send None to clear the instructions. """ - tone: NotRequired[Optional[str]] + tone: NotRequired[Optional[InboxAgentTone]] """ - The tone the agent writes in. Use one of the presets - ``concise_professional``, ``concise_casual``, ``expanded_professional``, - or ``expanded_casual``, or describe the tone in your own words, such as - ``friendly and concise``. Custom text is max 64 characters. Leading and - trailing whitespace is trimmed, and the trimmed value must not be empty. - Send None to clear the tone. + The tone the agent writes in. Send None to clear the tone. """ - enabled_actions: NotRequired[List[InboxAgentAction]] + actions: NotRequired[List[InboxAgentAction]] """ The actions the agent can take. Replaces the whole set. Duplicates are removed. Send an empty list to disable all actions. @@ -81,16 +77,16 @@ def get(cls, inbox_id: str) -> InboxAgentSettings: """ Retrieve the agent settings of an inbox. An inbox without a configured agent returns empty settings: ``instructions`` and ``tone`` are None and - ``enabled_actions`` is an empty list. + ``actions`` is an empty list. see more: https://resend.com/docs/api-reference/inboxes/get-agent Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. Returns: InboxAgentSettings: The agent settings of the inbox """ - path = f"/inboxes/{inbox_id}/agent" + path = f"{inbox_path(inbox_id)}/agent" resp = request.Request[InboxAgentSettings]( path=path, params={}, verb="get" ).perform_with_content() @@ -109,7 +105,7 @@ def update(cls, params: UpdateParams) -> UpdateResponse: Returns: UpdateResponse: The object type and the ID of the inbox agent """ - path = f"/inboxes/{params['inbox_id']}/agent" + path = f"{inbox_path(params['inbox_id'])}/agent" resp = request.Request[InboxAgent.UpdateResponse]( path=path, params=without_keys(params, "inbox_id"), verb="patch" ).perform_with_content() @@ -120,16 +116,16 @@ async def get_async(cls, inbox_id: str) -> InboxAgentSettings: """ Retrieve the agent settings of an inbox (async). An inbox without a configured agent returns empty settings: ``instructions`` and ``tone`` - are None and ``enabled_actions`` is an empty list. + are None and ``actions`` is an empty list. see more: https://resend.com/docs/api-reference/inboxes/get-agent Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. Returns: InboxAgentSettings: The agent settings of the inbox """ - path = f"/inboxes/{inbox_id}/agent" + path = f"{inbox_path(inbox_id)}/agent" resp = await AsyncRequest[InboxAgentSettings]( path=path, params={}, verb="get" ).perform_with_content() @@ -148,7 +144,7 @@ async def update_async(cls, params: UpdateParams) -> UpdateResponse: Returns: UpdateResponse: The object type and the ID of the inbox agent """ - path = f"/inboxes/{params['inbox_id']}/agent" + path = f"{inbox_path(params['inbox_id'])}/agent" resp = await AsyncRequest[InboxAgent.UpdateResponse]( path=path, params=without_keys(params, "inbox_id"), verb="patch" ).perform_with_content() diff --git a/resend/inboxes/agent/_inbox_agent_settings.py b/resend/inboxes/agent/_inbox_agent_settings.py index 2191f35..8ebfca3 100644 --- a/resend/inboxes/agent/_inbox_agent_settings.py +++ b/resend/inboxes/agent/_inbox_agent_settings.py @@ -10,13 +10,23 @@ "add_labels", "assign_thread", "mark_as_spam", - "archive_thread", - "delete_thread", + "archive", + "move_to_trash", ] """ An action the inbox agent can take. """ +InboxAgentTone = Literal[ + "concise_professional", + "concise_casual", + "expanded_professional", + "expanded_casual", +] +""" +A tone preset the inbox agent writes in. +""" + class InboxAgentSettings(BaseResponse): """ @@ -32,15 +42,11 @@ class InboxAgentSettings(BaseResponse): Instructions the agent follows when it handles the threads of the inbox. None when no instructions are set. """ - tone: Optional[str] + tone: Optional[InboxAgentTone] """ - The tone the agent writes in. One of the presets ``concise_professional``, - ``concise_casual``, ``expanded_professional``, or ``expanded_casual``, or - custom text in the owner's own words, such as ``friendly and concise``. - Older dashboard values such as ``Concise · Casual`` are custom text. - None when no tone is set. + The tone the agent writes in. None when no tone is set. """ - enabled_actions: List[InboxAgentAction] + actions: List[InboxAgentAction] """ The actions the agent can take. Empty when no actions are enabled. """ diff --git a/resend/inboxes/drafts/_inbox_drafts.py b/resend/inboxes/drafts/_inbox_drafts.py index 0a17c3d..701601e 100644 --- a/resend/inboxes/drafts/_inbox_drafts.py +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -4,7 +4,8 @@ from resend import request from resend._base_response import BaseResponse -from resend.inboxes._query import build_inbox_query_path, without_keys +from resend.inboxes._query import (build_inbox_query_path, inbox_path, + without_keys) from ._inbox_draft import InboxDraft, InboxDraftListItem @@ -103,7 +104,7 @@ class CreateParams(TypedDict): ``thread_id`` and ``reply_to_email_id`` must be sent together or not at all. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. to (NotRequired[Optional[Union[str, List[str]]]]): The recipients. cc (NotRequired[Optional[Union[str, List[str]]]]): The CC recipients. bcc (NotRequired[Optional[Union[str, List[str]]]]): The BCC recipients. @@ -117,7 +118,7 @@ class CreateParams(TypedDict): inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ to: NotRequired[Optional[Union[str, List[str]]]] """ @@ -211,7 +212,7 @@ class UpdateParams(TypedDict): one non-empty field, and its combined recipients cannot exceed 50. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. id (str): The Draft ID. to (NotRequired[Optional[Union[str, List[str]]]]): The recipients. cc (NotRequired[Optional[Union[str, List[str]]]]): The CC recipients. @@ -225,7 +226,7 @@ class UpdateParams(TypedDict): inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ id: str """ @@ -266,6 +267,24 @@ class UpdateParams(TypedDict): Send ``[]`` or None to remove them all. """ + class UpdateResponse(BaseResponse): + """ + UpdateResponse is the response of updating a draft. + + Attributes: + object (str): The object type, always "inbox_draft" + id (str): The ID of the draft + """ + + object: str + """ + The object type, always "inbox_draft". + """ + id: str + """ + The ID of the draft. + """ + class RemoveResponse(BaseResponse): """ RemoveResponse is the response of removing a draft. @@ -349,14 +368,14 @@ def list(cls, inbox_id: str, params: Optional[ListParams] = None) -> ListRespons see more: https://resend.com/docs/api-reference/inboxes/list-drafts Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. params (Optional[ListParams]): Optional pagination parameters Returns: ListResponse: A page of the unsent drafts """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/drafts", + f"{inbox_path(inbox_id)}/drafts", cast(Dict[Any, Any], params) if params else None, ) resp = request.Request[InboxDrafts.ListResponse]( @@ -391,7 +410,7 @@ def create( Returns: CreateResponse: The object type and the ID of the draft """ - path = f"/inboxes/{params['inbox_id']}/drafts" + path = f"{inbox_path(params['inbox_id'])}/drafts" resp = request.Request[InboxDrafts.CreateResponse]( path=path, params=without_keys(params, "inbox_id"), @@ -407,20 +426,20 @@ def get(cls, inbox_id: str, draft_id: str) -> InboxDraft: see more: https://resend.com/docs/api-reference/inboxes/get-draft Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. draft_id (str): The Draft ID. Returns: InboxDraft: The draft """ - path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + path = f"{inbox_path(inbox_id)}/drafts/{draft_id}" resp = request.Request[InboxDraft]( path=path, params={}, verb="get" ).perform_with_content() return resp @classmethod - def update(cls, params: UpdateParams) -> InboxDraft: + def update(cls, params: UpdateParams) -> UpdateResponse: """ Update the content of an unsent draft. Omitted fields are left unchanged. Sending ``attachments`` replaces every attachment on the draft. A request @@ -433,10 +452,10 @@ def update(cls, params: UpdateParams) -> InboxDraft: params (UpdateParams): The inbox ID, the draft ID and the fields to change Returns: - InboxDraft: The updated draft + UpdateResponse: The object type and the ID of the draft """ - path = f"/inboxes/{params['inbox_id']}/drafts/{params['id']}" - resp = request.Request[InboxDraft]( + path = f"{inbox_path(params['inbox_id'])}/drafts/{params['id']}" + resp = request.Request[InboxDrafts.UpdateResponse]( path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" ).perform_with_content() return resp @@ -449,13 +468,13 @@ def remove(cls, inbox_id: str, draft_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-draft Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. draft_id (str): The Draft ID. Returns: RemoveResponse: The draft ID and its deleted status """ - path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + path = f"{inbox_path(inbox_id)}/drafts/{draft_id}" resp = request.Request[InboxDrafts.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() @@ -478,7 +497,7 @@ def send( see more: https://resend.com/docs/api-reference/inboxes/send-draft Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. draft_id (str): The Draft ID. params (Optional[SendParams]): Optional body parameters, ie: headers options (Optional[SendOptions]): The send options, ie: idempotency_key @@ -486,7 +505,7 @@ def send( Returns: SendResponse: The ID of the sent email """ - path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" + path = f"{inbox_path(inbox_id)}/drafts/{draft_id}/send" resp = request.Request[InboxDrafts.SendResponse]( path=path, params=cast(Dict[Any, Any], params) if params else {}, @@ -505,14 +524,14 @@ async def list_async( see more: https://resend.com/docs/api-reference/inboxes/list-drafts Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. params (Optional[ListParams]): Optional pagination parameters Returns: ListResponse: A page of the unsent drafts """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/drafts", + f"{inbox_path(inbox_id)}/drafts", cast(Dict[Any, Any], params) if params else None, ) resp = await AsyncRequest[InboxDrafts.ListResponse]( @@ -547,7 +566,7 @@ async def create_async( Returns: CreateResponse: The object type and the ID of the draft """ - path = f"/inboxes/{params['inbox_id']}/drafts" + path = f"{inbox_path(params['inbox_id'])}/drafts" resp = await AsyncRequest[InboxDrafts.CreateResponse]( path=path, params=without_keys(params, "inbox_id"), @@ -563,20 +582,20 @@ async def get_async(cls, inbox_id: str, draft_id: str) -> InboxDraft: see more: https://resend.com/docs/api-reference/inboxes/get-draft Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. draft_id (str): The Draft ID. Returns: InboxDraft: The draft """ - path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + path = f"{inbox_path(inbox_id)}/drafts/{draft_id}" resp = await AsyncRequest[InboxDraft]( path=path, params={}, verb="get" ).perform_with_content() return resp @classmethod - async def update_async(cls, params: UpdateParams) -> InboxDraft: + async def update_async(cls, params: UpdateParams) -> UpdateResponse: """ Update the content of an unsent draft (async). Omitted fields are left unchanged. Sending ``attachments`` replaces every attachment on the draft. @@ -589,10 +608,10 @@ async def update_async(cls, params: UpdateParams) -> InboxDraft: params (UpdateParams): The inbox ID, the draft ID and the fields to change Returns: - InboxDraft: The updated draft + UpdateResponse: The object type and the ID of the draft """ - path = f"/inboxes/{params['inbox_id']}/drafts/{params['id']}" - resp = await AsyncRequest[InboxDraft]( + path = f"{inbox_path(params['inbox_id'])}/drafts/{params['id']}" + resp = await AsyncRequest[InboxDrafts.UpdateResponse]( path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" ).perform_with_content() return resp @@ -605,13 +624,13 @@ async def remove_async(cls, inbox_id: str, draft_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-draft Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. draft_id (str): The Draft ID. Returns: RemoveResponse: The draft ID and its deleted status """ - path = f"/inboxes/{inbox_id}/drafts/{draft_id}" + path = f"{inbox_path(inbox_id)}/drafts/{draft_id}" resp = await AsyncRequest[InboxDrafts.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() @@ -634,7 +653,7 @@ async def send_async( see more: https://resend.com/docs/api-reference/inboxes/send-draft Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. draft_id (str): The Draft ID. params (Optional[SendParams]): Optional body parameters, ie: headers options (Optional[SendOptions]): The send options, ie: idempotency_key @@ -642,7 +661,7 @@ async def send_async( Returns: SendResponse: The ID of the sent email """ - path = f"/inboxes/{inbox_id}/drafts/{draft_id}/send" + path = f"{inbox_path(inbox_id)}/drafts/{draft_id}/send" resp = await AsyncRequest[InboxDrafts.SendResponse]( path=path, params=cast(Dict[Any, Any], params) if params else {}, diff --git a/resend/inboxes/labels/_inbox_labels.py b/resend/inboxes/labels/_inbox_labels.py index f5d34a4..fa9f74f 100644 --- a/resend/inboxes/labels/_inbox_labels.py +++ b/resend/inboxes/labels/_inbox_labels.py @@ -4,7 +4,7 @@ from resend import request from resend._base_response import BaseResponse -from resend.inboxes._query import without_keys +from resend.inboxes._query import inbox_path, without_keys from ._inbox_label import InboxLabel @@ -44,14 +44,14 @@ class CreateParams(TypedDict): CreateParams are the parameters for creating a label on an inbox. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. name (str): The name of the label. color (NotRequired[str]): The color of the label, as a #RRGGBB hex color. """ inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ name: str """ @@ -88,7 +88,7 @@ class UpdateParams(TypedDict): Omitted fields keep their current value. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. id (str): The Label ID. name (NotRequired[str]): A new name for the label. color (NotRequired[str]): A new color for the label, as a #RRGGBB hex color. @@ -96,7 +96,7 @@ class UpdateParams(TypedDict): inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ id: str """ @@ -160,12 +160,12 @@ def list(cls, inbox_id: str) -> ListResponse: see more: https://resend.com/docs/api-reference/inboxes/list-labels Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. Returns: ListResponse: The labels on the inbox """ - path = f"/inboxes/{inbox_id}/labels" + path = f"{inbox_path(inbox_id)}/labels" resp = request.Request[InboxLabels.ListResponse]( path=path, params={}, verb="get" ).perform_with_content() @@ -185,7 +185,7 @@ def create(cls, params: CreateParams) -> CreateResponse: Returns: CreateResponse: The object type and the ID of the label """ - path = f"/inboxes/{params['inbox_id']}/labels" + path = f"{inbox_path(params['inbox_id'])}/labels" resp = request.Request[InboxLabels.CreateResponse]( path=path, params=without_keys(params, "inbox_id"), verb="post" ).perform_with_content() @@ -205,7 +205,7 @@ def update(cls, params: UpdateParams) -> UpdateResponse: Returns: UpdateResponse: The object type and the ID of the updated label """ - path = f"/inboxes/{params['inbox_id']}/labels/{params['id']}" + path = f"{inbox_path(params['inbox_id'])}/labels/{params['id']}" resp = request.Request[InboxLabels.UpdateResponse]( path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" ).perform_with_content() @@ -218,13 +218,13 @@ def remove(cls, inbox_id: str, label_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-label Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. label_id (str): The Label ID. Returns: RemoveResponse: The removed label ID and its deleted status """ - path = f"/inboxes/{inbox_id}/labels/{label_id}" + path = f"{inbox_path(inbox_id)}/labels/{label_id}" resp = request.Request[InboxLabels.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() @@ -238,12 +238,12 @@ async def list_async(cls, inbox_id: str) -> ListResponse: see more: https://resend.com/docs/api-reference/inboxes/list-labels Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. Returns: ListResponse: The labels on the inbox """ - path = f"/inboxes/{inbox_id}/labels" + path = f"{inbox_path(inbox_id)}/labels" resp = await AsyncRequest[InboxLabels.ListResponse]( path=path, params={}, verb="get" ).perform_with_content() @@ -263,7 +263,7 @@ async def create_async(cls, params: CreateParams) -> CreateResponse: Returns: CreateResponse: The object type and the ID of the label """ - path = f"/inboxes/{params['inbox_id']}/labels" + path = f"{inbox_path(params['inbox_id'])}/labels" resp = await AsyncRequest[InboxLabels.CreateResponse]( path=path, params=without_keys(params, "inbox_id"), verb="post" ).perform_with_content() @@ -283,7 +283,7 @@ async def update_async(cls, params: UpdateParams) -> UpdateResponse: Returns: UpdateResponse: The object type and the ID of the updated label """ - path = f"/inboxes/{params['inbox_id']}/labels/{params['id']}" + path = f"{inbox_path(params['inbox_id'])}/labels/{params['id']}" resp = await AsyncRequest[InboxLabels.UpdateResponse]( path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" ).perform_with_content() @@ -296,13 +296,13 @@ async def remove_async(cls, inbox_id: str, label_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-label Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. label_id (str): The Label ID. Returns: RemoveResponse: The removed label ID and its deleted status """ - path = f"/inboxes/{inbox_id}/labels/{label_id}" + path = f"{inbox_path(inbox_id)}/labels/{label_id}" resp = await AsyncRequest[InboxLabels.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() diff --git a/resend/inboxes/threads/_inbox_thread.py b/resend/inboxes/threads/_inbox_thread.py index 22c67b7..74a7934 100644 --- a/resend/inboxes/threads/_inbox_thread.py +++ b/resend/inboxes/threads/_inbox_thread.py @@ -1,6 +1,6 @@ from typing import List, Optional -from typing_extensions import NotRequired, TypedDict +from typing_extensions import TypedDict from resend._base_response import BaseResponse from resend.inboxes._inbox import InboxMessageFolder @@ -91,25 +91,6 @@ class InboxThread(_InboxThreadFrom): """ -# Functional syntax required because ``from`` is a reserved keyword. -InboxThreadSearchHighlights = TypedDict( - "InboxThreadSearchHighlights", - { - "subject": NotRequired[List[str]], - "body": NotRequired[List[str]], - "from": NotRequired[List[str]], - "to": NotRequired[List[str]], - "cc": NotRequired[List[str]], - "bcc": NotRequired[List[str]], - "attachments": NotRequired[List[str]], - }, -) -""" -Where ``query`` matched the email in ``matched_email_id``, as text fragments -with each match wrapped in ``**``. Only keys with a match are present. -""" - - class InboxThreadSearchResult(InboxThread): """ InboxThreadSearchResult is a thread returned by the search threads endpoint. @@ -117,18 +98,12 @@ class InboxThreadSearchResult(InboxThread): matched_email_id: Optional[str] """ - Identifies the email in the thread that matched the search. When ``query`` + The ID of the email in the thread that matched the search. When ``query`` has words or phrases, it is the most relevant match. Otherwise, it is the - newest. The get, reply and forward thread email endpoints accept it as - ``email_id``, but it is not equal to the ``id`` those endpoints return. + newest. It is the same ``id`` that the list thread emails endpoint returns, + so use it as ``email_id`` with the get, reply and forward endpoints. None when the search index returns no matched email for the thread. """ - highlights: InboxThreadSearchHighlights - """ - Where ``query`` matched the email in ``matched_email_id``, as text fragments - with each match wrapped in ``**``. Only keys with a match are present. - An empty dict when there is no ``query``. - """ class InboxThreadSummary(BaseResponse): diff --git a/resend/inboxes/threads/_inbox_threads.py b/resend/inboxes/threads/_inbox_threads.py index 40ada33..c8a13e1 100644 --- a/resend/inboxes/threads/_inbox_threads.py +++ b/resend/inboxes/threads/_inbox_threads.py @@ -5,11 +5,13 @@ from resend import request from resend._base_response import BaseResponse from resend.inboxes._inbox import InboxMessageFolder, MoveThreadFolder -from resend.inboxes._query import build_inbox_query_path, without_keys +from resend.inboxes._query import (build_inbox_query_path, inbox_path, + without_keys) from ._inbox_thread import (InboxThread, InboxThreadSearchResult, InboxThreadSummary) from .emails._inbox_thread_emails import InboxThreadEmails +from .labels._inbox_thread_labels import InboxThreadLabels # Async imports (optional - only available with pip install resend[async]) try: @@ -108,6 +110,9 @@ class InboxThreads: class Emails(InboxThreadEmails): pass + class Labels(InboxThreadLabels): + pass + class ListParams(_ListParams): """ ListParams are the filter and pagination parameters for listing threads. @@ -196,21 +201,19 @@ class SearchResponse(BaseResponse): class UpdateParams(TypedDict): """ UpdateParams are the parameters for updating a thread. At least one of - ``read``, ``folder``, ``add_label_ids``, or ``remove_label_ids`` is required. - A label ID cannot be both added and removed in the same request. + ``read`` or ``folder`` is required. To change the labels of a thread, + use ``Inboxes.Threads.Labels``. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. id (str): The Thread ID. read (NotRequired[bool]): Marks every email in the thread read or unread. folder (NotRequired[MoveThreadFolder]): Moves the thread to this folder. - add_label_ids (NotRequired[List[str]]): Label IDs to apply to the thread. - remove_label_ids (NotRequired[List[str]]): Label IDs to remove from the thread. """ inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ id: str """ @@ -226,16 +229,6 @@ class UpdateParams(TypedDict): cannot be set. Moving a thread out of ``trash`` restores it and clears its deletion deadline. """ - add_label_ids: NotRequired[List[str]] - """ - Label IDs to apply to the thread. Between 1 and 10 IDs. - A label ID that does not exist in the inbox returns a 404. - """ - remove_label_ids: NotRequired[List[str]] - """ - Label IDs to remove from the thread. Between 1 and 10 IDs. The labels - themselves are kept. A label ID that does not exist in the inbox returns a 404. - """ class RemoveResponse(BaseResponse): """ @@ -271,14 +264,14 @@ def list(cls, inbox_id: str, params: Optional[ListParams] = None) -> ListRespons see more: https://resend.com/docs/api-reference/inboxes/list-threads Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. params (Optional[ListParams]): Optional filter and pagination parameters Returns: ListResponse: A page of the threads of the inbox """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/threads", + f"{inbox_path(inbox_id)}/threads", cast(Dict[Any, Any], params) if params else None, ) resp = request.Request[InboxThreads.ListResponse]( @@ -303,14 +296,14 @@ def search( see more: https://resend.com/docs/api-reference/inboxes/search-threads Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. params (Optional[SearchParams]): Optional search, filter and pagination parameters Returns: SearchResponse: A page of the matching threads """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/threads/search", + f"{inbox_path(inbox_id)}/threads/search", cast(Dict[Any, Any], params) if params else None, ) resp = request.Request[InboxThreads.SearchResponse]( @@ -326,13 +319,13 @@ def get(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: see more: https://resend.com/docs/api-reference/inboxes/get-thread Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. Returns: InboxThreadSummary: The thread summary """ - path = f"/inboxes/{inbox_id}/threads/{thread_id}" + path = f"{inbox_path(inbox_id)}/threads/{thread_id}" resp = request.Request[InboxThreadSummary]( path=path, params={}, verb="get" ).perform_with_content() @@ -341,14 +334,11 @@ def get(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: @classmethod def update(cls, params: UpdateParams) -> InboxThreadSummary: """ - Mark a thread read or unread, move it to a folder, or add and remove labels. - At least one of ``read``, ``folder``, ``add_label_ids``, or - ``remove_label_ids`` is required. A label ID cannot be both added and - removed in the same request. Removing a label from a thread keeps the - label itself. Moving a thread out of ``trash`` restores it and clears its - deletion deadline. The changes apply in this order: ``read``, ``folder``, - added labels, removed labels. They are not atomic, so a 404 for a label - leaves the earlier changes applied. + Mark a thread read or unread, or move it to a folder. At least one of + ``read`` or ``folder`` is required. To change the labels of a thread, use + ``Inboxes.Threads.Labels``. Moving a thread out of ``trash`` restores it + and clears its deletion deadline. The changes apply in this order: + ``read``, then ``folder``. They are not atomic. see more: https://resend.com/docs/api-reference/inboxes/update-thread Args: @@ -357,7 +347,7 @@ def update(cls, params: UpdateParams) -> InboxThreadSummary: Returns: InboxThreadSummary: The updated thread summary """ - path = f"/inboxes/{params['inbox_id']}/threads/{params['id']}" + path = f"{inbox_path(params['inbox_id'])}/threads/{params['id']}" resp = request.Request[InboxThreadSummary]( path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" ).perform_with_content() @@ -372,13 +362,13 @@ def remove(cls, inbox_id: str, thread_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-thread Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. Returns: RemoveResponse: The thread ID and whether this call moved it to trash """ - path = f"/inboxes/{inbox_id}/threads/{thread_id}" + path = f"{inbox_path(inbox_id)}/threads/{thread_id}" resp = request.Request[InboxThreads.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() @@ -397,14 +387,14 @@ async def list_async( see more: https://resend.com/docs/api-reference/inboxes/list-threads Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. params (Optional[ListParams]): Optional filter and pagination parameters Returns: ListResponse: A page of the threads of the inbox """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/threads", + f"{inbox_path(inbox_id)}/threads", cast(Dict[Any, Any], params) if params else None, ) resp = await AsyncRequest[InboxThreads.ListResponse]( @@ -429,14 +419,14 @@ async def search_async( see more: https://resend.com/docs/api-reference/inboxes/search-threads Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. params (Optional[SearchParams]): Optional search, filter and pagination parameters Returns: SearchResponse: A page of the matching threads """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/threads/search", + f"{inbox_path(inbox_id)}/threads/search", cast(Dict[Any, Any], params) if params else None, ) resp = await AsyncRequest[InboxThreads.SearchResponse]( @@ -452,13 +442,13 @@ async def get_async(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: see more: https://resend.com/docs/api-reference/inboxes/get-thread Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. Returns: InboxThreadSummary: The thread summary """ - path = f"/inboxes/{inbox_id}/threads/{thread_id}" + path = f"{inbox_path(inbox_id)}/threads/{thread_id}" resp = await AsyncRequest[InboxThreadSummary]( path=path, params={}, verb="get" ).perform_with_content() @@ -467,14 +457,11 @@ async def get_async(cls, inbox_id: str, thread_id: str) -> InboxThreadSummary: @classmethod async def update_async(cls, params: UpdateParams) -> InboxThreadSummary: """ - Mark a thread read or unread, move it to a folder, or add and remove - labels (async). At least one of ``read``, ``folder``, ``add_label_ids``, - or ``remove_label_ids`` is required. A label ID cannot be both added and - removed in the same request. Removing a label from a thread keeps the - label itself. Moving a thread out of ``trash`` restores it and clears its - deletion deadline. The changes apply in this order: ``read``, ``folder``, - added labels, removed labels. They are not atomic, so a 404 for a label - leaves the earlier changes applied. + Mark a thread read or unread, or move it to a folder (async). At least + one of ``read`` or ``folder`` is required. To change the labels of a + thread, use ``Inboxes.Threads.Labels``. Moving a thread out of ``trash`` + restores it and clears its deletion deadline. The changes apply in this + order: ``read``, then ``folder``. They are not atomic. see more: https://resend.com/docs/api-reference/inboxes/update-thread Args: @@ -483,7 +470,7 @@ async def update_async(cls, params: UpdateParams) -> InboxThreadSummary: Returns: InboxThreadSummary: The updated thread summary """ - path = f"/inboxes/{params['inbox_id']}/threads/{params['id']}" + path = f"{inbox_path(params['inbox_id'])}/threads/{params['id']}" resp = await AsyncRequest[InboxThreadSummary]( path=path, params=without_keys(params, "inbox_id", "id"), verb="patch" ).perform_with_content() @@ -498,13 +485,13 @@ async def remove_async(cls, inbox_id: str, thread_id: str) -> RemoveResponse: see more: https://resend.com/docs/api-reference/inboxes/delete-thread Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. Returns: RemoveResponse: The thread ID and whether this call moved it to trash """ - path = f"/inboxes/{inbox_id}/threads/{thread_id}" + path = f"{inbox_path(inbox_id)}/threads/{thread_id}" resp = await AsyncRequest[InboxThreads.RemoveResponse]( path=path, params={}, verb="delete" ).perform_with_content() diff --git a/resend/inboxes/threads/emails/_inbox_thread_emails.py b/resend/inboxes/threads/emails/_inbox_thread_emails.py index 2d943a5..8c2354c 100644 --- a/resend/inboxes/threads/emails/_inbox_thread_emails.py +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -5,7 +5,8 @@ from resend import request from resend._base_response import BaseResponse from resend.emails._attachment import Attachment, RemoteAttachment -from resend.inboxes._query import build_inbox_query_path, without_keys +from resend.inboxes._query import (build_inbox_query_path, inbox_path, + without_keys) from ._inbox_thread_email import InboxThreadEmail @@ -77,7 +78,7 @@ class ReplyParams(TypedDict): At least one of ``html`` or ``text`` must contain more than whitespace. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. email_id (str): The Email ID of the message to reply to. html (NotRequired[str]): The HTML body of the reply. @@ -92,7 +93,7 @@ class ReplyParams(TypedDict): inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ thread_id: str """ @@ -152,9 +153,9 @@ class ReplyParams(TypedDict): attachments: NotRequired[List[Union[Attachment, RemoteAttachment]]] """ Files to attach, as on ``Emails.send``. Each one needs ``content`` or - ``path``. Up to 10 files. The ``content`` of all files can total at most - 25 MB. A ``path`` file counts toward the 10 files only, because it is - fetched when the email is sent. + ``path``. The ``content`` of all files can total at most 25 MB. A + ``path`` file does not count toward the 25 MB, because it is fetched when + the email is sent. An empty file, a blocked file type, or a ``path`` that is not an ``http`` or ``https`` URL or points to ``localhost`` returns a 422. """ @@ -199,7 +200,7 @@ class ForwardParams(TypedDict): ForwardParams are the parameters for forwarding a thread email. Attributes: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. email_id (str): The Email ID of the message to forward. to (Union[str, List[str]]): The recipients. @@ -214,7 +215,7 @@ class ForwardParams(TypedDict): inbox_id: str """ - The Inbox ID. An email address is not accepted. + The Inbox ID or the inbox email address. """ thread_id: str """ @@ -266,9 +267,9 @@ class ForwardParams(TypedDict): """ Files to attach, as on ``Emails.send``. Each one needs ``content`` or ``path``. They share one limit with the attachments of the original - message, which the forward also sends. Together they can have up to 10 - files and at most 25 MB of ``content``. A ``path`` file counts toward the - 10 files only, because it is fetched when the email is sent. + message, which the forward also sends. Together they can have at most + 25 MB of ``content``. A ``path`` file does not count toward the 25 MB, + because it is fetched when the email is sent. An empty file, a blocked file type, or a ``path`` that is not an ``http`` or ``https`` URL or points to ``localhost`` returns a 422. """ @@ -319,7 +320,7 @@ def list( see more: https://resend.com/docs/api-reference/inboxes/list-thread-emails Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. params (Optional[ListParams]): Optional pagination parameters @@ -327,7 +328,7 @@ def list( ListResponse: A page of the messages of the thread """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/threads/{thread_id}/emails", + f"{inbox_path(inbox_id)}/threads/{thread_id}/emails", cast(Dict[Any, Any], params) if params else None, ) resp = request.Request[InboxThreadEmails.ListResponse]( @@ -342,7 +343,7 @@ def get(cls, inbox_id: str, thread_id: str, email_id: str) -> InboxThreadEmail: see more: https://resend.com/docs/api-reference/inboxes/get-thread-email Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. email_id (str): The Email ID, as returned in ``data[].id`` by the list thread emails endpoint. @@ -350,7 +351,7 @@ def get(cls, inbox_id: str, thread_id: str, email_id: str) -> InboxThreadEmail: Returns: InboxThreadEmail: The thread email """ - path = f"/inboxes/{inbox_id}/threads/{thread_id}/emails/{email_id}" + path = f"{inbox_path(inbox_id)}/threads/{thread_id}/emails/{email_id}" resp = request.Request[InboxThreadEmail]( path=path, params={}, verb="get" ).perform_with_content() @@ -378,7 +379,7 @@ def reply( ReplyResponse: The ID of the sent email """ path = ( - f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}" f"/emails/{params['email_id']}/reply" ) resp = request.Request[InboxThreadEmails.ReplyResponse]( @@ -398,7 +399,7 @@ def forward( The original message is quoted under a forwarded-message banner, below the optional ``html`` or ``text`` note. The attachments of the original message are sent with the forward, together with any ``attachments`` you add. - The request fails when they exceed 10 files or 25 MB of ``content`` in total. + The request fails when they exceed 25 MB of ``content`` in total. An original attachment that was sent from a URL (``path``) cannot be forwarded, and the request returns a 422. see more: https://resend.com/docs/api-reference/inboxes/forward-thread-email @@ -411,7 +412,7 @@ def forward( ForwardResponse: The ID of the sent email """ path = ( - f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}" f"/emails/{params['email_id']}/forward" ) resp = request.Request[InboxThreadEmails.ForwardResponse]( @@ -433,7 +434,7 @@ async def list_async( see more: https://resend.com/docs/api-reference/inboxes/list-thread-emails Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. params (Optional[ListParams]): Optional pagination parameters @@ -441,7 +442,7 @@ async def list_async( ListResponse: A page of the messages of the thread """ path = build_inbox_query_path( - f"/inboxes/{inbox_id}/threads/{thread_id}/emails", + f"{inbox_path(inbox_id)}/threads/{thread_id}/emails", cast(Dict[Any, Any], params) if params else None, ) resp = await AsyncRequest[InboxThreadEmails.ListResponse]( @@ -458,7 +459,7 @@ async def get_async( see more: https://resend.com/docs/api-reference/inboxes/get-thread-email Args: - inbox_id (str): The Inbox ID. An email address is not accepted. + inbox_id (str): The Inbox ID or the inbox email address. thread_id (str): The Thread ID. email_id (str): The Email ID, as returned in ``data[].id`` by the list thread emails endpoint. @@ -466,7 +467,7 @@ async def get_async( Returns: InboxThreadEmail: The thread email """ - path = f"/inboxes/{inbox_id}/threads/{thread_id}/emails/{email_id}" + path = f"{inbox_path(inbox_id)}/threads/{thread_id}/emails/{email_id}" resp = await AsyncRequest[InboxThreadEmail]( path=path, params={}, verb="get" ).perform_with_content() @@ -494,7 +495,7 @@ async def reply_async( ReplyResponse: The ID of the sent email """ path = ( - f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}" f"/emails/{params['email_id']}/reply" ) resp = await AsyncRequest[InboxThreadEmails.ReplyResponse]( @@ -514,9 +515,9 @@ async def forward_async( draft (async). The original message is quoted under a forwarded-message banner, below the optional ``html`` or ``text`` note. The attachments of the original message are sent with the forward, together with any - ``attachments`` you add. The request fails when they exceed 10 files or - 25 MB of ``content`` in total. An original attachment that was sent from a - URL (``path``) cannot be forwarded, and the request returns a 422. + ``attachments`` you add. The request fails when they exceed 25 MB of + ``content`` in total. An original attachment that was sent from a URL + (``path``) cannot be forwarded, and the request returns a 422. see more: https://resend.com/docs/api-reference/inboxes/forward-thread-email Args: @@ -527,7 +528,7 @@ async def forward_async( ForwardResponse: The ID of the sent email """ path = ( - f"/inboxes/{params['inbox_id']}/threads/{params['thread_id']}" + f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}" f"/emails/{params['email_id']}/forward" ) resp = await AsyncRequest[InboxThreadEmails.ForwardResponse]( diff --git a/resend/inboxes/threads/labels/__init__.py b/resend/inboxes/threads/labels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resend/inboxes/threads/labels/_inbox_thread_labels.py b/resend/inboxes/threads/labels/_inbox_thread_labels.py new file mode 100644 index 0000000..f542979 --- /dev/null +++ b/resend/inboxes/threads/labels/_inbox_thread_labels.py @@ -0,0 +1,226 @@ +from typing import List + +from typing_extensions import TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.inboxes._query import inbox_path, without_keys +from resend.inboxes.threads._inbox_thread import InboxThreadLabel + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class InboxThreadLabels: + """ + InboxThreadLabels manages the labels on an inbox thread. To create, rename + or delete the labels themselves, use ``Inboxes.Labels``. + """ + + class ListResponse(BaseResponse): + """ + ListResponse lists every label on the thread after the change. + + Attributes: + object (str): The object type, always "list" + data (List[InboxThreadLabel]): The labels on the thread + """ + + object: str + """ + The object type, always "list". + """ + data: List[InboxThreadLabel] + """ + The labels on the thread after the change. + """ + + class AddParams(TypedDict): + """ + AddParams are the parameters for adding labels to a thread. + + Attributes: + inbox_id (str): The Inbox ID or the inbox email address. + thread_id (str): The Thread ID. + labels (List[str]): The IDs of the labels to apply to the thread. + """ + + inbox_id: str + """ + The Inbox ID or the inbox email address. + """ + thread_id: str + """ + The Thread ID. + """ + labels: List[str] + """ + The IDs of the labels to apply to the thread. Between 1 and 10 IDs, + each a label of the inbox. + """ + + class SetParams(TypedDict): + """ + SetParams are the parameters for replacing the labels of a thread. + + Attributes: + inbox_id (str): The Inbox ID or the inbox email address. + thread_id (str): The Thread ID. + labels (List[str]): The IDs of the labels the thread carries after the change. + """ + + inbox_id: str + """ + The Inbox ID or the inbox email address. + """ + thread_id: str + """ + The Thread ID. + """ + labels: List[str] + """ + The IDs of the labels the thread carries after the change. Up to 100 IDs, + each a label of the inbox. Pass an empty list to take every label off + the thread. + """ + + @classmethod + def add(cls, params: AddParams) -> ListResponse: + """ + Apply one or more labels to a thread. Labels already on the thread stay + on it. A label ID that is not a label of the inbox returns a 404, and + nothing changes. To replace the labels of the thread, use ``set``. + see more: https://resend.com/docs/api-reference/inboxes/add-thread-labels + + Args: + params (AddParams): The inbox ID, the thread ID and the label IDs + + Returns: + ListResponse: Every label on the thread after the change + """ + path = f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}/labels" + resp = request.Request[InboxThreadLabels.ListResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id"), + verb="post", + ).perform_with_content() + return resp + + @classmethod + def set(cls, params: SetParams) -> ListResponse: + """ + Replace every label on a thread with the given labels, in one atomic + change. The labels themselves are kept. An empty list takes every label + off the thread. A label ID that is not a label of the inbox returns a + 404, and nothing changes. + see more: https://resend.com/docs/api-reference/inboxes/set-thread-labels + + Args: + params (SetParams): The inbox ID, the thread ID and the label IDs + + Returns: + ListResponse: Every label on the thread after the change + """ + path = f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}/labels" + resp = request.Request[InboxThreadLabels.ListResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id"), + verb="put", + ).perform_with_content() + return resp + + @classmethod + def remove(cls, inbox_id: str, thread_id: str, label_id: str) -> ListResponse: + """ + Take one label off a thread. The label itself is kept, and stays on every + other thread that carries it. A label that is not on the thread returns + the current labels. To delete the label, use ``Inboxes.Labels.remove``. + see more: https://resend.com/docs/api-reference/inboxes/remove-thread-label + + Args: + inbox_id (str): The Inbox ID or the inbox email address. + thread_id (str): The Thread ID. + label_id (str): The Label ID. + + Returns: + ListResponse: Every label on the thread after the change + """ + path = f"{inbox_path(inbox_id)}/threads/{thread_id}/labels/{label_id}" + resp = request.Request[InboxThreadLabels.ListResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def add_async(cls, params: AddParams) -> ListResponse: + """ + Apply one or more labels to a thread (async). Labels already on the + thread stay on it. A label ID that is not a label of the inbox returns a + 404, and nothing changes. To replace the labels of the thread, use + ``set_async``. + see more: https://resend.com/docs/api-reference/inboxes/add-thread-labels + + Args: + params (AddParams): The inbox ID, the thread ID and the label IDs + + Returns: + ListResponse: Every label on the thread after the change + """ + path = f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}/labels" + resp = await AsyncRequest[InboxThreadLabels.ListResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id"), + verb="post", + ).perform_with_content() + return resp + + @classmethod + async def set_async(cls, params: SetParams) -> ListResponse: + """ + Replace every label on a thread with the given labels, in one atomic + change (async). The labels themselves are kept. An empty list takes every + label off the thread. A label ID that is not a label of the inbox returns + a 404, and nothing changes. + see more: https://resend.com/docs/api-reference/inboxes/set-thread-labels + + Args: + params (SetParams): The inbox ID, the thread ID and the label IDs + + Returns: + ListResponse: Every label on the thread after the change + """ + path = f"{inbox_path(params['inbox_id'])}/threads/{params['thread_id']}/labels" + resp = await AsyncRequest[InboxThreadLabels.ListResponse]( + path=path, + params=without_keys(params, "inbox_id", "thread_id"), + verb="put", + ).perform_with_content() + return resp + + @classmethod + async def remove_async( + cls, inbox_id: str, thread_id: str, label_id: str + ) -> ListResponse: + """ + Take one label off a thread (async). The label itself is kept, and stays + on every other thread that carries it. A label that is not on the thread + returns the current labels. To delete the label, use + ``Inboxes.Labels.remove_async``. + see more: https://resend.com/docs/api-reference/inboxes/remove-thread-label + + Args: + inbox_id (str): The Inbox ID or the inbox email address. + thread_id (str): The Thread ID. + label_id (str): The Label ID. + + Returns: + ListResponse: Every label on the thread after the change + """ + path = f"{inbox_path(inbox_id)}/threads/{thread_id}/labels/{label_id}" + resp = await AsyncRequest[InboxThreadLabels.ListResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/tests/inboxes_async_test.py b/tests/inboxes_async_test.py index e3823a6..31f0ee8 100644 --- a/tests/inboxes_async_test.py +++ b/tests/inboxes_async_test.py @@ -5,7 +5,7 @@ from tests.conftest import AsyncResendBaseTest from tests.inboxes_test import (DRAFT, DRAFT_ID, EMAIL_ID, INBOX, INBOX_ID, LABEL_ID, SENT_EMAIL_ID, THREAD, THREAD_EMAIL, - THREAD_ID, THREAD_SUMMARY) + THREAD_ID, THREAD_LABEL, THREAD_SUMMARY) # flake8: noqa @@ -88,13 +88,13 @@ async def test_inbox_agent_get_async(self) -> None: "object": "inbox_agent", "instructions": "Escalate legal threats.", "tone": None, - "enabled_actions": ["draft_reply"], + "actions": ["draft_reply"], } ) agent: resend.InboxAgentSettings = await resend.Inboxes.Agent.get_async( INBOX_ID ) - assert agent["enabled_actions"] == ["draft_reply"] + assert agent["actions"] == ["draft_reply"] async def test_should_get_inbox_agent_async_raise_exception_when_no_content( self, @@ -109,7 +109,7 @@ async def test_inbox_agent_update_async(self) -> None: ) agent: resend.Inboxes.Agent.UpdateResponse = ( await resend.Inboxes.Agent.update_async( - {"inbox_id": INBOX_ID, "enabled_actions": []} + {"inbox_id": INBOX_ID, "actions": []} ) ) assert agent["object"] == "inbox_agent" @@ -208,13 +208,11 @@ async def test_should_list_inbox_threads_async_raise_exception_when_no_content( async def test_inbox_threads_search_async(self) -> None: result = dict(THREAD) result["matched_email_id"] = None - result["highlights"] = {} self.set_mock_json({"object": "list", "has_more": False, "data": [result]}) threads: resend.Inboxes.Threads.SearchResponse = ( await resend.Inboxes.Threads.search_async(INBOX_ID, {"query": "refund"}) ) assert threads["data"][0]["matched_email_id"] is None - assert threads["data"][0]["highlights"] == {} async def test_should_search_inbox_threads_async_raise_exception_when_no_content( self, @@ -240,7 +238,7 @@ async def test_should_get_inbox_threads_async_raise_exception_when_no_content( async def test_inbox_threads_update_async(self) -> None: self.set_mock_json(THREAD_SUMMARY) thread: resend.InboxThreadSummary = await resend.Inboxes.Threads.update_async( - {"inbox_id": INBOX_ID, "id": THREAD_ID, "remove_label_ids": [LABEL_ID]} + {"inbox_id": INBOX_ID, "id": THREAD_ID, "folder": "spam"} ) assert thread["id"] == THREAD_ID @@ -270,6 +268,62 @@ async def test_should_remove_inbox_threads_async_raise_exception_when_no_content _ = await resend.Inboxes.Threads.remove_async(INBOX_ID, THREAD_ID) +class TestResendInboxThreadLabelsAsync(AsyncResendBaseTest): + async def test_inbox_thread_labels_add_async(self) -> None: + self.set_mock_json({"object": "list", "data": [THREAD_LABEL]}) + labels: resend.Inboxes.Threads.Labels.ListResponse = ( + await resend.Inboxes.Threads.Labels.add_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": [LABEL_ID]} + ) + ) + assert labels["data"] == [THREAD_LABEL] + + async def test_should_add_inbox_thread_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Labels.add_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": [LABEL_ID]} + ) + + async def test_inbox_thread_labels_set_async(self) -> None: + self.set_mock_json({"object": "list", "data": [THREAD_LABEL]}) + labels: resend.Inboxes.Threads.Labels.ListResponse = ( + await resend.Inboxes.Threads.Labels.set_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": [LABEL_ID]} + ) + ) + assert labels["object"] == "list" + + async def test_should_set_inbox_thread_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Labels.set_async( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": []} + ) + + async def test_inbox_thread_labels_remove_async(self) -> None: + self.set_mock_json({"object": "list", "data": []}) + labels: resend.Inboxes.Threads.Labels.ListResponse = ( + await resend.Inboxes.Threads.Labels.remove_async( + INBOX_ID, THREAD_ID, LABEL_ID + ) + ) + assert labels["data"] == [] + + async def test_should_remove_inbox_thread_labels_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Inboxes.Threads.Labels.remove_async( + INBOX_ID, THREAD_ID, LABEL_ID + ) + + class TestResendInboxThreadEmailsAsync(AsyncResendBaseTest): async def test_inbox_thread_emails_list_async(self) -> None: self.set_mock_json({"object": "list", "has_more": False, "data": [THREAD_EMAIL]}) @@ -432,11 +486,13 @@ async def test_should_get_inbox_drafts_async_raise_exception_when_no_content( _ = await resend.Inboxes.Drafts.get_async(INBOX_ID, DRAFT_ID) async def test_inbox_drafts_update_async(self) -> None: - self.set_mock_json(DRAFT) - draft: resend.InboxDraft = await resend.Inboxes.Drafts.update_async( - {"inbox_id": INBOX_ID, "id": DRAFT_ID, "subject": "Refund for order 1041"} + self.set_mock_json({"object": "inbox_draft", "id": DRAFT_ID}) + draft: resend.Inboxes.Drafts.UpdateResponse = ( + await resend.Inboxes.Drafts.update_async( + {"inbox_id": INBOX_ID, "id": DRAFT_ID, "subject": "Refund for order 1041"} + ) ) - assert draft["subject"] == "Refund for order 1041" + assert draft == {"object": "inbox_draft", "id": DRAFT_ID} async def test_should_update_inbox_drafts_async_raise_exception_when_no_content( self, diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py index 0156b93..f045e81 100644 --- a/tests/inboxes_test.py +++ b/tests/inboxes_test.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Callable, Dict, List, Tuple from unittest import TestCase from unittest.mock import create_autospec @@ -28,6 +28,7 @@ "drafts": 2, "last_received": "2026-08-05T14:03:11.229Z", "created_at": "2026-08-01T09:12:44.512Z", + "updated_at": "2026-08-02T10:00:00.000Z", } THREAD_LABEL: Dict[str, Any] = {"id": LABEL_ID, "name": "Urgent", "color": "#E93D82"} @@ -137,6 +138,8 @@ def test_inboxes_list(self) -> None: "from_name": None, "unread": 3, "last_received": None, + "created_at": "2026-08-01T09:12:44.512Z", + "updated_at": "2026-08-02T10:00:00.000Z", } ], } @@ -147,6 +150,7 @@ def test_inboxes_list(self) -> None: assert inboxes["data"][0]["id"] == INBOX_ID assert inboxes["data"][0]["email_address"] == "support@acme.com" assert inboxes["data"][0]["from_name"] is None + assert inboxes["data"][0]["updated_at"] == "2026-08-02T10:00:00.000Z" def test_should_list_inboxes_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) @@ -161,6 +165,7 @@ def test_inboxes_get(self) -> None: assert inbox["receiving_address"] is None assert inbox["unread"] == 3 assert inbox["drafts"] == 2 + assert inbox["updated_at"] == "2026-08-02T10:00:00.000Z" def test_should_get_inboxes_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) @@ -198,13 +203,13 @@ def test_inbox_agent_get(self) -> None: "object": "inbox_agent", "instructions": None, "tone": None, - "enabled_actions": [], + "actions": [], } ) agent: resend.InboxAgentSettings = resend.Inboxes.Agent.get(INBOX_ID) assert agent["object"] == "inbox_agent" assert agent["instructions"] is None - assert agent["enabled_actions"] == [] + assert agent["actions"] == [] def test_should_get_inbox_agent_raise_exception_when_no_content(self) -> None: self.set_mock_json(None) @@ -217,8 +222,8 @@ def test_inbox_agent_update(self) -> None: ) params: resend.Inboxes.Agent.UpdateParams = { "inbox_id": INBOX_ID, - "tone": "friendly and concise", - "enabled_actions": ["draft_reply", "add_labels"], + "tone": "concise_casual", + "actions": ["draft_reply", "archive", "move_to_trash"], } agent: resend.Inboxes.Agent.UpdateResponse = resend.Inboxes.Agent.update( params @@ -350,8 +355,7 @@ def test_should_list_inbox_threads_raise_exception_when_no_content( def test_inbox_threads_search(self) -> None: result = dict(THREAD) - result["matched_email_id"] = "e8a3f1c6-2b9d-4e5a-a7c4-9d1b6f3e2a80" - result["highlights"] = {"subject": ["September **invoice**"]} + result["matched_email_id"] = EMAIL_ID self.set_mock_json({"object": "list", "has_more": False, "data": [result]}) params: resend.Inboxes.Threads.SearchParams = { "query": "invoice -draft", @@ -362,8 +366,8 @@ def test_inbox_threads_search(self) -> None: ) assert threads["object"] == "list" match = threads["data"][0] - assert match["matched_email_id"] == "e8a3f1c6-2b9d-4e5a-a7c4-9d1b6f3e2a80" - assert match["highlights"]["subject"] == ["September **invoice**"] + assert match["matched_email_id"] == EMAIL_ID + assert "highlights" not in match def test_should_search_inbox_threads_raise_exception_when_no_content( self, @@ -398,7 +402,6 @@ def test_inbox_threads_update(self) -> None: "id": THREAD_ID, "read": True, "folder": "archive", - "add_label_ids": [LABEL_ID], } thread: resend.InboxThreadSummary = resend.Inboxes.Threads.update(params) assert thread["folder"] == "archive" @@ -429,6 +432,66 @@ def test_should_remove_inbox_threads_raise_exception_when_no_content( _ = resend.Inboxes.Threads.remove(INBOX_ID, THREAD_ID) +class TestResendInboxThreadLabels(ResendBaseTest): + def test_inbox_thread_labels_add(self) -> None: + self.set_mock_json({"object": "list", "data": [THREAD_LABEL]}) + params: resend.Inboxes.Threads.Labels.AddParams = { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "labels": [LABEL_ID], + } + labels: resend.Inboxes.Threads.Labels.ListResponse = ( + resend.Inboxes.Threads.Labels.add(params) + ) + assert labels["object"] == "list" + assert labels["data"] == [THREAD_LABEL] + + def test_should_add_inbox_thread_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Labels.add( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": [LABEL_ID]} + ) + + def test_inbox_thread_labels_set(self) -> None: + self.set_mock_json({"object": "list", "data": []}) + params: resend.Inboxes.Threads.Labels.SetParams = { + "inbox_id": INBOX_ID, + "thread_id": THREAD_ID, + "labels": [], + } + labels: resend.Inboxes.Threads.Labels.ListResponse = ( + resend.Inboxes.Threads.Labels.set(params) + ) + assert labels["data"] == [] + + def test_should_set_inbox_thread_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Labels.set( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": []} + ) + + def test_inbox_thread_labels_remove(self) -> None: + self.set_mock_json({"object": "list", "data": []}) + labels: resend.Inboxes.Threads.Labels.ListResponse = ( + resend.Inboxes.Threads.Labels.remove(INBOX_ID, THREAD_ID, LABEL_ID) + ) + assert labels["object"] == "list" + assert labels["data"] == [] + + def test_should_remove_inbox_thread_labels_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.Inboxes.Threads.Labels.remove(INBOX_ID, THREAD_ID, LABEL_ID) + + class TestResendInboxThreadEmails(ResendBaseTest): def test_inbox_thread_emails_list(self) -> None: self.set_mock_json({"object": "list", "has_more": False, "data": [THREAD_EMAIL]}) @@ -606,18 +669,17 @@ def test_should_get_inbox_drafts_raise_exception_when_no_content( _ = resend.Inboxes.Drafts.get(INBOX_ID, DRAFT_ID) def test_inbox_drafts_update(self) -> None: - draft_json = dict(DRAFT) - draft_json["html"] = None - self.set_mock_json(draft_json) + self.set_mock_json({"object": "inbox_draft", "id": DRAFT_ID}) params: resend.Inboxes.Drafts.UpdateParams = { "inbox_id": INBOX_ID, "id": DRAFT_ID, "text": "Refund issued for order 1041.", "cc": None, } - draft: resend.InboxDraft = resend.Inboxes.Drafts.update(params) - assert draft["id"] == DRAFT_ID - assert draft["html"] is None + draft: resend.Inboxes.Drafts.UpdateResponse = resend.Inboxes.Drafts.update( + params + ) + assert draft == {"object": "inbox_draft", "id": DRAFT_ID} def test_should_update_inbox_drafts_raise_exception_when_no_content( self, @@ -716,12 +778,103 @@ def test_agent_get_and_update(self) -> None: assert self.last_request()["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}/agent" resend.Inboxes.Agent.update( - {"inbox_id": INBOX_ID, "instructions": None, "enabled_actions": []} + {"inbox_id": INBOX_ID, "instructions": None, "actions": []} ) req = self.last_request() assert req["method"] == "patch" assert req["url"] == f"https://api.resend.com/inboxes/{INBOX_ID}/agent" - assert req["json"] == {"instructions": None, "enabled_actions": []} + assert req["json"] == {"instructions": None, "actions": []} + + def test_every_inbox_route_encodes_an_email_address(self) -> None: + base = "https://api.resend.com/inboxes/support%40acme.com" + calls: List[Tuple[Callable[[], Any], str]] = [ + (lambda: resend.Inboxes.update({"id": "support@acme.com", "name": "S"}), ""), + (lambda: resend.Inboxes.remove("support@acme.com"), ""), + (lambda: resend.Inboxes.Agent.get("support@acme.com"), "/agent"), + ( + lambda: resend.Inboxes.Agent.update( + {"inbox_id": "support@acme.com", "tone": None} + ), + "/agent", + ), + (lambda: resend.Inboxes.Labels.list("support@acme.com"), "/labels"), + ( + lambda: resend.Inboxes.Labels.create( + {"inbox_id": "support@acme.com", "name": "Urgent"} + ), + "/labels", + ), + ( + lambda: resend.Inboxes.Labels.update( + {"inbox_id": "support@acme.com", "id": LABEL_ID, "name": "U"} + ), + f"/labels/{LABEL_ID}", + ), + ( + lambda: resend.Inboxes.Labels.remove("support@acme.com", LABEL_ID), + f"/labels/{LABEL_ID}", + ), + (lambda: resend.Inboxes.Threads.list("support@acme.com"), "/threads"), + ( + lambda: resend.Inboxes.Threads.search("support@acme.com"), + "/threads/search", + ), + ( + lambda: resend.Inboxes.Threads.get("support@acme.com", THREAD_ID), + f"/threads/{THREAD_ID}", + ), + ( + lambda: resend.Inboxes.Threads.update( + {"inbox_id": "support@acme.com", "id": THREAD_ID, "read": True} + ), + f"/threads/{THREAD_ID}", + ), + ( + lambda: resend.Inboxes.Threads.remove("support@acme.com", THREAD_ID), + f"/threads/{THREAD_ID}", + ), + ( + lambda: resend.Inboxes.Threads.Labels.add( + { + "inbox_id": "support@acme.com", + "thread_id": THREAD_ID, + "labels": [LABEL_ID], + } + ), + f"/threads/{THREAD_ID}/labels", + ), + ( + lambda: resend.Inboxes.Threads.Emails.list( + "support@acme.com", THREAD_ID + ), + f"/threads/{THREAD_ID}/emails", + ), + ( + lambda: resend.Inboxes.Threads.Emails.reply( + { + "inbox_id": "support@acme.com", + "thread_id": THREAD_ID, + "email_id": EMAIL_ID, + "text": "Hi", + } + ), + f"/threads/{THREAD_ID}/emails/{EMAIL_ID}/reply", + ), + (lambda: resend.Inboxes.Drafts.list("support@acme.com"), "/drafts"), + ( + lambda: resend.Inboxes.Drafts.create( + {"inbox_id": "support@acme.com", "text": "Hi"} + ), + "/drafts", + ), + ( + lambda: resend.Inboxes.Drafts.send("support@acme.com", DRAFT_ID), + f"/drafts/{DRAFT_ID}/send", + ), + ] + for call, suffix in calls: + call() + assert self.last_request()["url"] == f"{base}{suffix}" def test_labels_paths_and_bodies(self) -> None: base = f"https://api.resend.com/inboxes/{INBOX_ID}/labels" @@ -788,26 +941,38 @@ def test_threads_get_update_and_remove(self) -> None: assert self.last_request()["url"] == url resend.Inboxes.Threads.update( - { - "inbox_id": INBOX_ID, - "id": THREAD_ID, - "folder": "archive", - "add_label_ids": [LABEL_ID], - "remove_label_ids": ["2b6d0f4e-8a1c-4e3b-9d57-1c0e8a6f4b21"], - } + {"inbox_id": INBOX_ID, "id": THREAD_ID, "folder": "archive"} ) req = self.last_request() assert (req["method"], req["url"]) == ("patch", url) - assert req["json"] == { - "folder": "archive", - "add_label_ids": [LABEL_ID], - "remove_label_ids": ["2b6d0f4e-8a1c-4e3b-9d57-1c0e8a6f4b21"], - } + assert req["json"] == {"folder": "archive"} resend.Inboxes.Threads.remove(INBOX_ID, THREAD_ID) req = self.last_request() assert (req["method"], req["url"]) == ("delete", url) + def test_thread_labels_paths_and_bodies(self) -> None: + base = f"https://api.resend.com/inboxes/{INBOX_ID}/threads/{THREAD_ID}/labels" + resend.Inboxes.Threads.Labels.add( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": [LABEL_ID]} + ) + req = self.last_request() + assert (req["method"], req["url"], req["json"]) == ( + "post", + base, + {"labels": [LABEL_ID]}, + ) + + resend.Inboxes.Threads.Labels.set( + {"inbox_id": INBOX_ID, "thread_id": THREAD_ID, "labels": []} + ) + req = self.last_request() + assert (req["method"], req["url"], req["json"]) == ("put", base, {"labels": []}) + + resend.Inboxes.Threads.Labels.remove(INBOX_ID, THREAD_ID, LABEL_ID) + req = self.last_request() + assert (req["method"], req["url"]) == ("delete", f"{base}/{LABEL_ID}") + def test_thread_emails_paths_and_bodies(self) -> None: base = f"https://api.resend.com/inboxes/{INBOX_ID}/threads/{THREAD_ID}/emails" resend.Inboxes.Threads.Emails.list(INBOX_ID, THREAD_ID, {"before": EMAIL_ID})