Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
11 changes: 11 additions & 0 deletions nylas/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down
63 changes: 63 additions & 0 deletions nylas/models/transactional_send.py
Original file line number Diff line number Diff line change
@@ -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]
73 changes: 73 additions & 0 deletions nylas/resources/transactional_send.py
Original file line number Diff line number Diff line change
@@ -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)
125 changes: 125 additions & 0 deletions tests/resources/test_transactional_send.py
Original file line number Diff line number Diff line change
@@ -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,
)
5 changes: 5 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
Loading