Skip to content

Commit 6853659

Browse files
authored
Add Transactional Send (POST /v3/domains/{domain_name}/messages/send) (#465)
1 parent 2910584 commit 6853659

6 files changed

Lines changed: 278 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ nylas-python Changelog
22
======================
33
Unreleased
44
----------
5+
* 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`)
56

67
v6.14.3
78
----------

nylas/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from nylas.resources.folders import Folders
1010
from nylas.resources.messages import Messages
1111
from nylas.resources.threads import Threads
12+
from nylas.resources.transactional_send import TransactionalSend
1213
from nylas.resources.webhooks import Webhooks
1314
from nylas.resources.contacts import Contacts
1415
from nylas.resources.drafts import Drafts
@@ -162,6 +163,16 @@ def threads(self) -> Threads:
162163
"""
163164
return Threads(self.http_client)
164165

166+
@property
167+
def transactional_send(self) -> TransactionalSend:
168+
"""
169+
Access the Transactional Send API.
170+
171+
Returns:
172+
The Transactional Send API.
173+
"""
174+
return TransactionalSend(self.http_client)
175+
165176
@property
166177
def webhooks(self) -> Webhooks:
167178
"""

nylas/models/transactional_send.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import Any, Dict, List
2+
3+
from typing_extensions import NotRequired, Required, TypedDict
4+
5+
from nylas.models.attachments import CreateAttachmentRequest
6+
from nylas.models.drafts import CustomHeader, TrackingOptions
7+
from nylas.models.events import EmailName
8+
9+
10+
class TransactionalTemplate(TypedDict, total=False):
11+
"""
12+
Template selection for a transactional send request.
13+
14+
Attributes:
15+
id: The template ID.
16+
strict: When true, Nylas returns an error if the template contains undefined variables.
17+
variables: Key/value pairs substituted into the template.
18+
"""
19+
20+
id: Required[str]
21+
strict: NotRequired[bool]
22+
variables: NotRequired[Dict[str, Any]]
23+
24+
25+
class TransactionalSendMessageRequest(TypedDict, total=False):
26+
"""
27+
Request body for POST /v3/domains/{domain_name}/messages/send.
28+
29+
Use ``from_`` for the sender; it is serialized as JSON ``from`` (``from`` is a Python keyword).
30+
31+
Attributes:
32+
to: Recipients (required by the API).
33+
from_: Sender ``email`` / optional ``name`` (required by the API).
34+
subject: Subject line.
35+
body: HTML or plain body depending on ``is_plaintext``.
36+
cc: CC recipients.
37+
bcc: BCC recipients.
38+
reply_to: Reply-To recipients.
39+
attachments: File attachments.
40+
send_at: Unix timestamp to send the message later.
41+
reply_to_message_id: Message being replied to.
42+
tracking_options: Open/link tracking settings.
43+
custom_headers: Custom MIME headers.
44+
metadata: String-keyed metadata.
45+
is_plaintext: Send body as plain text when true.
46+
template: Application template to render (optional vs. body/subject).
47+
"""
48+
49+
to: Required[List[EmailName]]
50+
from_: Required[EmailName]
51+
subject: NotRequired[str]
52+
body: NotRequired[str]
53+
cc: NotRequired[List[EmailName]]
54+
bcc: NotRequired[List[EmailName]]
55+
reply_to: NotRequired[List[EmailName]]
56+
attachments: NotRequired[List[CreateAttachmentRequest]]
57+
send_at: NotRequired[int]
58+
reply_to_message_id: NotRequired[str]
59+
tracking_options: NotRequired[TrackingOptions]
60+
custom_headers: NotRequired[List[CustomHeader]]
61+
metadata: NotRequired[Dict[str, Any]]
62+
is_plaintext: NotRequired[bool]
63+
template: NotRequired[TransactionalTemplate]
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import io
2+
import urllib.parse
3+
4+
from nylas.config import RequestOverrides
5+
from nylas.models.messages import Message
6+
from nylas.models.response import Response
7+
from nylas.models.transactional_send import TransactionalSendMessageRequest
8+
from nylas.resources.resource import Resource
9+
from nylas.utils.file_utils import (
10+
MAXIMUM_JSON_ATTACHMENT_SIZE,
11+
_build_form_request,
12+
encode_stream_to_base64,
13+
)
14+
15+
16+
class TransactionalSend(Resource):
17+
"""
18+
Nylas Transactional Send API.
19+
20+
Send email from a verified domain without a grant context.
21+
"""
22+
23+
def send(
24+
self,
25+
domain_name: str,
26+
request_body: TransactionalSendMessageRequest,
27+
overrides: RequestOverrides = None,
28+
) -> Response[Message]:
29+
"""
30+
Send a transactional email from the specified domain.
31+
32+
Args:
33+
domain_name: The domain Nylas sends from (must be verified in the dashboard).
34+
request_body: Message fields; use ``from_`` for the sender (maps to JSON ``from``).
35+
overrides: Per-request overrides for the HTTP client.
36+
37+
Returns:
38+
The sent message in a ``Response``.
39+
"""
40+
path = (
41+
f"/v3/domains/{urllib.parse.quote(domain_name, safe='')}/messages/send"
42+
)
43+
form_data = None
44+
json_body = None
45+
46+
if "from_" in request_body and "from" not in request_body:
47+
request_body["from"] = request_body["from_"]
48+
del request_body["from_"]
49+
50+
attachment_size = sum(
51+
attachment.get("size", 0)
52+
for attachment in request_body.get("attachments", [])
53+
)
54+
if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE:
55+
form_data = _build_form_request(request_body)
56+
else:
57+
for attachment in request_body.get("attachments", []):
58+
if issubclass(type(attachment["content"]), io.IOBase):
59+
attachment["content"] = encode_stream_to_base64(
60+
attachment["content"]
61+
)
62+
63+
json_body = request_body
64+
65+
json_response, headers = self._http_client._execute(
66+
method="POST",
67+
path=path,
68+
request_body=json_body,
69+
data=form_data,
70+
overrides=overrides,
71+
)
72+
73+
return Response.from_dict(json_response, Message, headers)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from unittest.mock import Mock, patch
2+
3+
from nylas.resources.transactional_send import TransactionalSend
4+
5+
6+
class TestTransactionalSend:
7+
def test_send_transactional_message(self, http_client_response):
8+
transactional_send = TransactionalSend(http_client_response)
9+
request_body = {
10+
"subject": "Welcome",
11+
"to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}],
12+
"from_": {"name": "ACME Support", "email": "support@acme.com"},
13+
"body": "Welcome to ACME.",
14+
}
15+
16+
transactional_send.send(domain_name="mail.acme.com", request_body=request_body)
17+
18+
http_client_response._execute.assert_called_once_with(
19+
method="POST",
20+
path="/v3/domains/mail.acme.com/messages/send",
21+
request_body={
22+
"subject": "Welcome",
23+
"to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}],
24+
"from": {"name": "ACME Support", "email": "support@acme.com"},
25+
"body": "Welcome to ACME.",
26+
},
27+
data=None,
28+
overrides=None,
29+
)
30+
31+
def test_send_domain_name_url_encoded(self, http_client_response):
32+
transactional_send = TransactionalSend(http_client_response)
33+
request_body = {
34+
"to": [{"email": "a@b.com"}],
35+
"from_": {"email": "support@acme.com"},
36+
}
37+
38+
transactional_send.send(
39+
domain_name="weird/slash.com",
40+
request_body=request_body,
41+
)
42+
43+
http_client_response._execute.assert_called_once_with(
44+
method="POST",
45+
path="/v3/domains/weird%2Fslash.com/messages/send",
46+
request_body={
47+
"to": [{"email": "a@b.com"}],
48+
"from": {"email": "support@acme.com"},
49+
},
50+
data=None,
51+
overrides=None,
52+
)
53+
54+
def test_send_small_attachment(self, http_client_response):
55+
transactional_send = TransactionalSend(http_client_response)
56+
request_body = {
57+
"to": [{"email": "j@example.com"}],
58+
"from_": {"email": "support@acme.com"},
59+
"attachments": [
60+
{
61+
"filename": "file1.txt",
62+
"content_type": "text/plain",
63+
"content": "this is a file",
64+
"size": 3,
65+
},
66+
],
67+
}
68+
69+
transactional_send.send(domain_name="acme.com", request_body=request_body)
70+
71+
http_client_response._execute.assert_called_once_with(
72+
method="POST",
73+
path="/v3/domains/acme.com/messages/send",
74+
request_body=request_body,
75+
data=None,
76+
overrides=None,
77+
)
78+
79+
def test_send_large_attachment(self, http_client_response):
80+
transactional_send = TransactionalSend(http_client_response)
81+
mock_encoder = Mock()
82+
request_body = {
83+
"to": [{"email": "j@example.com"}],
84+
"from_": {"email": "support@acme.com"},
85+
"attachments": [
86+
{
87+
"filename": "file1.txt",
88+
"content_type": "text/plain",
89+
"content": "this is a file",
90+
"size": 3 * 1024 * 1024,
91+
},
92+
],
93+
}
94+
95+
with patch(
96+
"nylas.resources.transactional_send._build_form_request",
97+
return_value=mock_encoder,
98+
):
99+
transactional_send.send(domain_name="acme.com", request_body=request_body)
100+
101+
http_client_response._execute.assert_called_once_with(
102+
method="POST",
103+
path="/v3/domains/acme.com/messages/send",
104+
request_body=None,
105+
data=mock_encoder,
106+
overrides=None,
107+
)
108+
109+
def test_send_with_existing_from_field_unchanged(self, http_client_response):
110+
transactional_send = TransactionalSend(http_client_response)
111+
request_body = {
112+
"to": [{"email": "j@example.com"}],
113+
"from": {"email": "direct@acme.com"},
114+
"from_": {"email": "ignored@acme.com"},
115+
}
116+
117+
transactional_send.send(domain_name="acme.com", request_body=request_body)
118+
119+
http_client_response._execute.assert_called_once_with(
120+
method="POST",
121+
path="/v3/domains/acme.com/messages/send",
122+
request_body=request_body,
123+
data=None,
124+
overrides=None,
125+
)

tests/test_client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from nylas.resources.grants import Grants
1212
from nylas.resources.messages import Messages
1313
from nylas.resources.threads import Threads
14+
from nylas.resources.transactional_send import TransactionalSend
1415
from nylas.resources.webhooks import Webhooks
1516

1617

@@ -83,6 +84,10 @@ def test_client_threads_property(self, client):
8384
assert client.threads is not None
8485
assert type(client.threads) is Threads
8586

87+
def test_client_transactional_send_property(self, client):
88+
assert client.transactional_send is not None
89+
assert type(client.transactional_send) is TransactionalSend
90+
8691
def test_client_webhooks_property(self, client):
8792
assert client.webhooks is not None
8893
assert type(client.webhooks) is Webhooks

0 commit comments

Comments
 (0)