diff --git a/examples/async/inboxes_async.py b/examples/async/inboxes_async.py new file mode 100644 index 0000000..efcd85a --- /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.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) + ) + 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"][0]["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..ef7abc1 --- /dev/null +++ b/examples/inboxes.py @@ -0,0 +1,169 @@ +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['actions']}") +agent_params: resend.Inboxes.Agent.UpdateParams = { + "inbox_id": inbox_id, + "tone": "concise_casual", + "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']}: matched email {result['matched_email_id']}") + +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, + } + 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) + ) + email_id = emails["data"][0]["id"] + email: resend.InboxThreadEmail = resend.Inboxes.Threads.Emails.get( + inbox_id, thread_id, email_id + ) + print(f"First 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.", + "attachments": [ + { + "content": "UmVmdW5kIHJlY2VpcHQ=", + "filename": "receipt.txt", + "content_type": "text/plain", + } + ], +} +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: 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']]}") + +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']}", +} +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_params, send_options +) +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']}") diff --git a/resend/__init__.py b/resend/__init__.py index 0510536..6e5aa88 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -53,6 +53,26 @@ 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, + 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, + 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 @@ -91,6 +111,29 @@ 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, @@ -141,6 +184,13 @@ "Suppressions", "SuppressionsBatch", "Usage", + "Inboxes", + "InboxAgent", + "InboxLabels", + "InboxThreads", + "InboxThreadEmails", + "InboxThreadLabels", + "InboxDrafts", # Types "Audience", "Automation", @@ -229,6 +279,51 @@ "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", + "MoveThreadFolder", + "InboxAgentAction", + "InboxAgentSettings", + "InboxAgentTone", + "InboxLabel", + "InboxThread", + "InboxThreadLabel", + "InboxThreadSearchResult", + "InboxThreadSummary", + "InboxThreadEmail", + "InboxThreadEmailAttachment", + "InboxDraft", + "InboxDraftAttachment", + "InboxDraftListItem", + "InboxDraftType", "Topic", "OAuthGrant", "OAuthGrantClient", diff --git a/resend/inboxes/__init__.py b/resend/inboxes/__init__.py new file mode 100644 index 0000000..3c9da8e --- /dev/null +++ b/resend/inboxes/__init__.py @@ -0,0 +1,53 @@ +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, + InboxAgentTone) +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 +from resend.inboxes.labels._inbox_labels import InboxLabels +from resend.inboxes.threads._inbox_thread import (InboxThread, + InboxThreadLabel, + 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 +from resend.inboxes.threads.labels._inbox_thread_labels import \ + InboxThreadLabels + +__all__ = [ + "Inboxes", + "InboxAgent", + "InboxLabels", + "InboxThreads", + "InboxThreadEmails", + "InboxThreadLabels", + "InboxDrafts", + "Inbox", + "InboxListItem", + "InboxMessageFolder", + "MoveThreadFolder", + "InboxAgentAction", + "InboxAgentSettings", + "InboxAgentTone", + "InboxLabel", + "InboxThread", + "InboxThreadLabel", + "InboxThreadSearchResult", + "InboxThreadSummary", + "InboxThreadEmail", + "InboxThreadEmailAttachment", + "InboxDraft", + "InboxDraftAttachment", + "InboxDraftListItem", + "InboxDraftType", +] diff --git a/resend/inboxes/_inbox.py b/resend/inboxes/_inbox.py new file mode 100644 index 0000000..126ed4b --- /dev/null +++ b/resend/inboxes/_inbox.py @@ -0,0 +1,117 @@ +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. + """ + updated_at: str + """ + ISO 8601 timestamp when the inbox was last updated. + """ + + +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. + """ + 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 new file mode 100644 index 0000000..cd3a470 --- /dev/null +++ b/resend/inboxes/_inboxes.py @@ -0,0 +1,431 @@ +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 Inbox, InboxListItem +from resend.inboxes._query import inbox_path, 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 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 or the inbox email address. + """ + 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. A blocked account + returns a 451. + 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. 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: + 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 = inbox_path(inbox_id) + 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 = inbox_path(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 or the inbox email address. + + Returns: + RemoveResponse: The inbox ID and its deleted status + """ + path = inbox_path(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. A blocked account + returns a 451. + 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. 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: + 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 = inbox_path(inbox_id) + 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 = inbox_path(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 or the inbox email address. + + Returns: + RemoveResponse: The inbox ID and its deleted status + """ + path = inbox_path(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..edd2bf2 --- /dev/null +++ b/resend/inboxes/_query.py @@ -0,0 +1,51 @@ +from typing import Any, Dict, Mapping, Optional +from urllib.parse import quote + +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 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") + 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)): + if value: + 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} + + +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/__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..4c5193b --- /dev/null +++ b/resend/inboxes/agent/_inbox_agent.py @@ -0,0 +1,151 @@ +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 inbox_path, without_keys + +from ._inbox_agent_settings import (InboxAgentAction, InboxAgentSettings, + InboxAgentTone) + +# 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 ``actions`` must be + provided. Omitted fields keep their current value. + + Attributes: + inbox_id (str): The Inbox ID or the inbox email address. + instructions (NotRequired[Optional[str]]): Instructions for the agent. + 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 or the inbox email address. + """ + 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[InboxAgentTone]] + """ + The tone the agent writes in. Send None to clear the tone. + """ + 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. + """ + + 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 + ``actions`` is an empty list. + see more: https://resend.com/docs/api-reference/inboxes/get-agent + + Args: + inbox_id (str): The Inbox ID or the inbox email address. + + Returns: + InboxAgentSettings: The agent settings of the inbox + """ + path = f"{inbox_path(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"{inbox_path(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 ``actions`` is an empty list. + see more: https://resend.com/docs/api-reference/inboxes/get-agent + + Args: + inbox_id (str): The Inbox ID or the inbox email address. + + Returns: + InboxAgentSettings: The agent settings of the inbox + """ + path = f"{inbox_path(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"{inbox_path(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..8ebfca3 --- /dev/null +++ b/resend/inboxes/agent/_inbox_agent_settings.py @@ -0,0 +1,52 @@ +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", + "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): + """ + 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[InboxAgentTone] + """ + The tone the agent writes in. None when no tone is set. + """ + 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..375c520 --- /dev/null +++ b/resend/inboxes/drafts/_inbox_draft.py @@ -0,0 +1,155 @@ +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 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. + """ + + 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. + """ + attachments: List[InboxDraftAttachment] + """ + The files stored on the draft. + """ + + +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. + """ + 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 new file mode 100644 index 0000000..701601e --- /dev/null +++ b/resend/inboxes/drafts/_inbox_drafts.py @@ -0,0 +1,671 @@ +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, inbox_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 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. It cannot be empty. + """ + 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. + 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``, ``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. + + Attributes: + 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. + 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. + """ + + inbox_id: str + """ + The Inbox ID or the inbox email address. + """ + 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. + """ + 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. 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] + """ + 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 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 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): + """ + 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``, ``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. + + Attributes: + 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. + 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. + attachments (NotRequired[Optional[List[AttachmentParams]]]): Files that replace + every attachment on the draft. + """ + + 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. 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. + """ + 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 ``[]`` 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. + + 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 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. 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): + """ + 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 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): + """ + 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 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"{inbox_path(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, 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 + 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. 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. 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: + 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"{inbox_path(params['inbox_id'])}/drafts" + resp = request.Request[InboxDrafts.CreateResponse]( + path=path, + params=without_keys(params, "inbox_id"), + verb="post", + options=cast(Dict[Any, Any], options), + ).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 or the inbox email address. + draft_id (str): The Draft ID. + + Returns: + InboxDraft: The draft + """ + 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) -> UpdateResponse: + """ + Update the content of an unsent draft. Omitted fields are left 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 + + Args: + params (UpdateParams): The inbox ID, the draft ID and the fields to change + + Returns: + UpdateResponse: The object type and the ID of the draft + """ + 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 + + @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 or the inbox email address. + draft_id (str): The Draft ID. + + Returns: + RemoveResponse: The draft ID and its deleted status + """ + path = f"{inbox_path(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, + 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 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 + + Returns: + SendResponse: The ID of the sent email + """ + 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 {}, + verb="post", + options=cast(Dict[Any, Any], options), + ).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 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"{inbox_path(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, 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 + 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. 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. 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: + 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"{inbox_path(params['inbox_id'])}/drafts" + resp = await AsyncRequest[InboxDrafts.CreateResponse]( + path=path, + params=without_keys(params, "inbox_id"), + verb="post", + options=cast(Dict[Any, Any], options), + ).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 or the inbox email address. + draft_id (str): The Draft ID. + + Returns: + InboxDraft: The draft + """ + 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) -> UpdateResponse: + """ + Update the content of an unsent draft (async). Omitted fields are left + 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 + + Args: + params (UpdateParams): The inbox ID, the draft ID and the fields to change + + Returns: + UpdateResponse: The object type and the ID of the draft + """ + 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 + + @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 or the inbox email address. + draft_id (str): The Draft ID. + + Returns: + RemoveResponse: The draft ID and its deleted status + """ + path = f"{inbox_path(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, + 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. 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 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 + + Returns: + SendResponse: The ID of the sent email + """ + 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 {}, + verb="post", + options=cast(Dict[Any, Any], options), + ).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..fa9f74f --- /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 inbox_path, 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 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 or the inbox email address. + """ + 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 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. + """ + + 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. 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 or the inbox email address. + + Returns: + ListResponse: The labels on the inbox + """ + path = f"{inbox_path(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"{inbox_path(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"{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() + 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 or the inbox email address. + label_id (str): The Label ID. + + Returns: + RemoveResponse: The removed label ID and its deleted status + """ + path = f"{inbox_path(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 or the inbox email address. + + Returns: + ListResponse: The labels on the inbox + """ + path = f"{inbox_path(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"{inbox_path(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"{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() + 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 or the inbox email address. + label_id (str): The Label ID. + + Returns: + RemoveResponse: The removed label ID and its deleted status + """ + path = f"{inbox_path(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..74a7934 --- /dev/null +++ b/resend/inboxes/threads/_inbox_thread.py @@ -0,0 +1,137 @@ +from typing import List, Optional + +from typing_extensions import 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. + """ + email_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. + """ + + +class InboxThreadSearchResult(InboxThread): + """ + InboxThreadSearchResult is a thread returned by the search threads endpoint. + """ + + matched_email_id: Optional[str] + """ + 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. 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. + """ + + +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..c8a13e1 --- /dev/null +++ b/resend/inboxes/threads/_inbox_threads.py @@ -0,0 +1,498 @@ +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, 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: + 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 Labels(InboxThreadLabels): + 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`` or ``folder`` is required. To change the labels of a thread, + use ``Inboxes.Threads.Labels``. + + Attributes: + 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. + """ + + 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. ``sent`` is assigned automatically and + cannot be set. Moving a thread out of ``trash`` restores it and clears its + deletion deadline. + """ + + 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. + 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 + + Args: + 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"{inbox_path(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. 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: + 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"{inbox_path(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 or the inbox email address. + thread_id (str): The Thread ID. + + Returns: + InboxThreadSummary: The thread summary + """ + path = f"{inbox_path(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, 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: + params (UpdateParams): The inbox ID, the thread ID and the changes + + Returns: + InboxThreadSummary: The updated thread summary + """ + 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() + 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 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"{inbox_path(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. 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: + 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"{inbox_path(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. 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: + 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"{inbox_path(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 or the inbox email address. + thread_id (str): The Thread ID. + + Returns: + InboxThreadSummary: The thread summary + """ + path = f"{inbox_path(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, 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: + params (UpdateParams): The inbox ID, the thread ID and the changes + + Returns: + InboxThreadSummary: The updated thread summary + """ + 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() + 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 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"{inbox_path(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..8c2354c --- /dev/null +++ b/resend/inboxes/threads/emails/_inbox_thread_emails.py @@ -0,0 +1,540 @@ +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, inbox_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 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. + 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. + headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email. + attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): Files to attach. + """ + + 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, 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. 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. + """ + 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. 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]]] + """ + Files to attach, as on ``Emails.send``. Each one needs ``content`` or + ``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. + """ + + 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 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): + """ + 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 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. + 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. + headers (NotRequired[Dict[str, str]]): Custom headers to be added to the email. + attachments (NotRequired[List[Union[Attachment, RemoteAttachment]]]): Files to attach. + """ + + 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, 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. + """ + 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. 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]]] + """ + 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 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. + """ + + 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 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): + """ + 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``. 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: + inbox_id (str): The Inbox ID or the inbox email address. + 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"{inbox_path(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 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. + + Returns: + InboxThreadEmail: The thread email + """ + path = f"{inbox_path(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, 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 + 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 + options (Optional[ReplyOptions]): The reply options, ie: idempotency_key + + Returns: + ReplyResponse: The ID of the sent email + """ + path = ( + f"{inbox_path(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", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp + + @classmethod + 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 + 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 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: + 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 + """ + path = ( + f"{inbox_path(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", + options=cast(Dict[Any, Any], options), + ).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``. 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: + inbox_id (str): The Inbox ID or the inbox email address. + 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"{inbox_path(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 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. + + Returns: + InboxThreadEmail: The thread email + """ + path = f"{inbox_path(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, 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 + 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 + options (Optional[ReplyOptions]): The reply options, ie: idempotency_key + + Returns: + ReplyResponse: The ID of the sent email + """ + path = ( + f"{inbox_path(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", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp + + @classmethod + 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 + 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 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: + 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 + """ + path = ( + f"{inbox_path(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", + options=cast(Dict[Any, Any], options), + ).perform_with_content() + return resp 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/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: 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..fe2b776 100644 --- a/resend/webhooks/_webhook_event.py +++ b/resend/webhooks/_webhook_event.py @@ -2,6 +2,10 @@ 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 # Functional syntax required because ``from`` is a reserved keyword. @@ -429,6 +433,414 @@ 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. + """ + 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. + """ + attachments: List[InboxDraftAttachment] + """ + The files stored on the draft. + """ + + +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 +1025,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 +1161,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``. diff --git a/tests/inboxes_async_test.py b/tests/inboxes_async_test.py new file mode 100644 index 0000000..31f0ee8 --- /dev/null +++ b/tests/inboxes_async_test.py @@ -0,0 +1,627 @@ +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_LABEL, 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, + "actions": ["draft_reply"], + } + ) + agent: resend.InboxAgentSettings = await resend.Inboxes.Agent.get_async( + INBOX_ID + ) + assert agent["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, "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 + 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 + + 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, "folder": "spam"} + ) + 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 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]}) + 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 + 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, + {"headers": {"X-Ticket-Id": "4821"}}, + {"idempotency_key": "k"}, + ) + assert sent["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({"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 == {"object": "inbox_draft", "id": DRAFT_ID} + + 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) + + +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" diff --git a/tests/inboxes_test.py b/tests/inboxes_test.py new file mode 100644 index 0000000..f045e81 --- /dev/null +++ b/tests/inboxes_test.py @@ -0,0 +1,1144 @@ +from typing import Any, Callable, Dict, List, Tuple +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", + "updated_at": "2026-08-02T10:00:00.000Z", +} + +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], + "email_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_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, + "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", + "attachments": [DRAFT_ATTACHMENT], +} + + +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, + "created_at": "2026-08-01T09:12:44.512Z", + "updated_at": "2026-08-02T10:00:00.000Z", + } + ], + } + ) + 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 + 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) + 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 + 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) + 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, + "actions": [], + } + ) + agent: resend.InboxAgentSettings = resend.Inboxes.Agent.get(INBOX_ID) + assert agent["object"] == "inbox_agent" + assert agent["instructions"] is None + assert agent["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": "concise_casual", + "actions": ["draft_reply", "archive", "move_to_trash"], + } + 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"] = EMAIL_ID + 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"] == EMAIL_ID + assert "highlights" not in match + + 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", + } + 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 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]}) + 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", + "attachments": [], + } + ], + } + ) + 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." + assert drafts["data"][0]["attachments"] == [] + + 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.", + "attachments": [ + { + "content": "aGVsbG8=", + "filename": "note.txt", + "content_type": "text/plain", + } + ], + } + 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 + 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, + ) -> 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: + 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.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, + ) -> 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, "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, "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" + 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_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": ["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"} + ) + req = self.last_request() + assert (req["method"], req["url"]) == ("patch", url) + 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}) + 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}, + ) + + 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.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}") + + 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, 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"] == {}