From 8c6fc0d5de4b961413e006ff33c5177d20725b05 Mon Sep 17 00:00:00 2001 From: pengfeiye Date: Fri, 17 Apr 2026 10:41:08 -0400 Subject: [PATCH] Add Transactional Send (POST /v3/domains/{domain_name}/messages/send) --- CHANGELOG.md | 1 + nylas/client.py | 11 ++ nylas/models/transactional_send.py | 63 +++++++++++ nylas/resources/transactional_send.py | 73 ++++++++++++ tests/resources/test_transactional_send.py | 125 +++++++++++++++++++++ tests/test_client.py | 5 + 6 files changed, 278 insertions(+) create mode 100644 nylas/models/transactional_send.py create mode 100644 nylas/resources/transactional_send.py create mode 100644 tests/resources/test_transactional_send.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c218c5d..711a96f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ nylas-python Changelog ====================== Unreleased ---------- +* Added Transactional Send: `Client.transactional_send.send()` for `POST /v3/domains/{domain_name}/messages/send`, with `TransactionalSendMessageRequest` and `TransactionalTemplate` models (JSON and multipart send behavior aligned with grant `messages.send`) v6.14.3 ---------- diff --git a/nylas/client.py b/nylas/client.py index 00dbf94..50cee02 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -9,6 +9,7 @@ from nylas.resources.folders import Folders from nylas.resources.messages import Messages from nylas.resources.threads import Threads +from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks from nylas.resources.contacts import Contacts from nylas.resources.drafts import Drafts @@ -162,6 +163,16 @@ def threads(self) -> Threads: """ return Threads(self.http_client) + @property + def transactional_send(self) -> TransactionalSend: + """ + Access the Transactional Send API. + + Returns: + The Transactional Send API. + """ + return TransactionalSend(self.http_client) + @property def webhooks(self) -> Webhooks: """ diff --git a/nylas/models/transactional_send.py b/nylas/models/transactional_send.py new file mode 100644 index 0000000..b9b4a81 --- /dev/null +++ b/nylas/models/transactional_send.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, List + +from typing_extensions import NotRequired, Required, TypedDict + +from nylas.models.attachments import CreateAttachmentRequest +from nylas.models.drafts import CustomHeader, TrackingOptions +from nylas.models.events import EmailName + + +class TransactionalTemplate(TypedDict, total=False): + """ + Template selection for a transactional send request. + + Attributes: + id: The template ID. + strict: When true, Nylas returns an error if the template contains undefined variables. + variables: Key/value pairs substituted into the template. + """ + + id: Required[str] + strict: NotRequired[bool] + variables: NotRequired[Dict[str, Any]] + + +class TransactionalSendMessageRequest(TypedDict, total=False): + """ + Request body for POST /v3/domains/{domain_name}/messages/send. + + Use ``from_`` for the sender; it is serialized as JSON ``from`` (``from`` is a Python keyword). + + Attributes: + to: Recipients (required by the API). + from_: Sender ``email`` / optional ``name`` (required by the API). + subject: Subject line. + body: HTML or plain body depending on ``is_plaintext``. + cc: CC recipients. + bcc: BCC recipients. + reply_to: Reply-To recipients. + attachments: File attachments. + send_at: Unix timestamp to send the message later. + reply_to_message_id: Message being replied to. + tracking_options: Open/link tracking settings. + custom_headers: Custom MIME headers. + metadata: String-keyed metadata. + is_plaintext: Send body as plain text when true. + template: Application template to render (optional vs. body/subject). + """ + + to: Required[List[EmailName]] + from_: Required[EmailName] + subject: NotRequired[str] + body: NotRequired[str] + cc: NotRequired[List[EmailName]] + bcc: NotRequired[List[EmailName]] + reply_to: NotRequired[List[EmailName]] + attachments: NotRequired[List[CreateAttachmentRequest]] + send_at: NotRequired[int] + reply_to_message_id: NotRequired[str] + tracking_options: NotRequired[TrackingOptions] + custom_headers: NotRequired[List[CustomHeader]] + metadata: NotRequired[Dict[str, Any]] + is_plaintext: NotRequired[bool] + template: NotRequired[TransactionalTemplate] diff --git a/nylas/resources/transactional_send.py b/nylas/resources/transactional_send.py new file mode 100644 index 0000000..e6eae0e --- /dev/null +++ b/nylas/resources/transactional_send.py @@ -0,0 +1,73 @@ +import io +import urllib.parse + +from nylas.config import RequestOverrides +from nylas.models.messages import Message +from nylas.models.response import Response +from nylas.models.transactional_send import TransactionalSendMessageRequest +from nylas.resources.resource import Resource +from nylas.utils.file_utils import ( + MAXIMUM_JSON_ATTACHMENT_SIZE, + _build_form_request, + encode_stream_to_base64, +) + + +class TransactionalSend(Resource): + """ + Nylas Transactional Send API. + + Send email from a verified domain without a grant context. + """ + + def send( + self, + domain_name: str, + request_body: TransactionalSendMessageRequest, + overrides: RequestOverrides = None, + ) -> Response[Message]: + """ + Send a transactional email from the specified domain. + + Args: + domain_name: The domain Nylas sends from (must be verified in the dashboard). + request_body: Message fields; use ``from_`` for the sender (maps to JSON ``from``). + overrides: Per-request overrides for the HTTP client. + + Returns: + The sent message in a ``Response``. + """ + path = ( + f"/v3/domains/{urllib.parse.quote(domain_name, safe='')}/messages/send" + ) + form_data = None + json_body = None + + if "from_" in request_body and "from" not in request_body: + request_body["from"] = request_body["from_"] + del request_body["from_"] + + attachment_size = sum( + attachment.get("size", 0) + for attachment in request_body.get("attachments", []) + ) + if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE: + form_data = _build_form_request(request_body) + else: + for attachment in request_body.get("attachments", []): + if issubclass(type(attachment["content"]), io.IOBase): + attachment["content"] = encode_stream_to_base64( + attachment["content"] + ) + + json_body = request_body + + json_response, headers = self._http_client._execute( + method="POST", + path=path, + request_body=json_body, + data=form_data, + overrides=overrides, + ) + + return Response.from_dict(json_response, Message, headers) diff --git a/tests/resources/test_transactional_send.py b/tests/resources/test_transactional_send.py new file mode 100644 index 0000000..a4843da --- /dev/null +++ b/tests/resources/test_transactional_send.py @@ -0,0 +1,125 @@ +from unittest.mock import Mock, patch + +from nylas.resources.transactional_send import TransactionalSend + + +class TestTransactionalSend: + def test_send_transactional_message(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "subject": "Welcome", + "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}], + "from_": {"name": "ACME Support", "email": "support@acme.com"}, + "body": "Welcome to ACME.", + } + + transactional_send.send(domain_name="mail.acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/mail.acme.com/messages/send", + request_body={ + "subject": "Welcome", + "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}], + "from": {"name": "ACME Support", "email": "support@acme.com"}, + "body": "Welcome to ACME.", + }, + data=None, + overrides=None, + ) + + def test_send_domain_name_url_encoded(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "to": [{"email": "a@b.com"}], + "from_": {"email": "support@acme.com"}, + } + + transactional_send.send( + domain_name="weird/slash.com", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/weird%2Fslash.com/messages/send", + request_body={ + "to": [{"email": "a@b.com"}], + "from": {"email": "support@acme.com"}, + }, + data=None, + overrides=None, + ) + + def test_send_small_attachment(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "to": [{"email": "j@example.com"}], + "from_": {"email": "support@acme.com"}, + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + } + + transactional_send.send(domain_name="acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/acme.com/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_large_attachment(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + mock_encoder = Mock() + request_body = { + "to": [{"email": "j@example.com"}], + "from_": {"email": "support@acme.com"}, + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], + } + + with patch( + "nylas.resources.transactional_send._build_form_request", + return_value=mock_encoder, + ): + transactional_send.send(domain_name="acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/acme.com/messages/send", + request_body=None, + data=mock_encoder, + overrides=None, + ) + + def test_send_with_existing_from_field_unchanged(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "to": [{"email": "j@example.com"}], + "from": {"email": "direct@acme.com"}, + "from_": {"email": "ignored@acme.com"}, + } + + transactional_send.send(domain_name="acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/acme.com/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) diff --git a/tests/test_client.py b/tests/test_client.py index 5818184..ac3d23b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,6 +11,7 @@ from nylas.resources.grants import Grants from nylas.resources.messages import Messages from nylas.resources.threads import Threads +from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks @@ -83,6 +84,10 @@ def test_client_threads_property(self, client): assert client.threads is not None assert type(client.threads) is Threads + def test_client_transactional_send_property(self, client): + assert client.transactional_send is not None + assert type(client.transactional_send) is TransactionalSend + def test_client_webhooks_property(self, client): assert client.webhooks is not None assert type(client.webhooks) is Webhooks