From 2f7868912061cf996e19183351b7ea14e6fee307 Mon Sep 17 00:00:00 2001 From: Zhi Qu Date: Thu, 30 Jul 2026 12:19:16 -0700 Subject: [PATCH] Add custom tracking hostname support --- CHANGELOG.md | 4 + .../custom_tracking_domain_demo/README.md | 61 +++++++ .../custom_tracking_domain_example.py | 162 ++++++++++++++++++ nylas/models/drafts.py | 8 +- nylas/models/messages.py | 4 + nylas/models/transactional_send.py | 3 +- nylas/resources/transactional_send.py | 4 +- tests/resources/test_drafts.py | 72 ++++++++ tests/resources/test_messages.py | 104 ++++++++++- tests/resources/test_transactional_send.py | 74 ++++++++ 10 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 examples/custom_tracking_domain_demo/README.md create mode 100644 examples/custom_tracking_domain_demo/custom_tracking_domain_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47dde70..c823906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ nylas-python Changelog ====================== +Unreleased +---------- +* Added optional `tracking_options.domain_name` support for custom link and open tracking hostnames in message sends, scheduled sends, drafts, and Transactional Send + v6.17.0 ---------- * Clarify that event `default` visibility is Google-only diff --git a/examples/custom_tracking_domain_demo/README.md b/examples/custom_tracking_domain_demo/README.md new file mode 100644 index 0000000..f4fcff3 --- /dev/null +++ b/examples/custom_tracking_domain_demo/README.md @@ -0,0 +1,61 @@ +# Custom Tracking Hostname Example + +This example shows how to set the optional `tracking_options.domain_name` field for: + +- a regular grant-based message; +- a draft; +- a scheduled grant-based message; and +- a Transactional Send message. + +The hostname must belong to the authenticated organization and have an active certificate. Enable `links`, `opens`, or both when you provide it. If you omit `domain_name`, Nylas keeps using its regional tracking hostname and existing request behavior is unchanged. + +For Transactional Send, the two domain values are intentionally different: + +- `NYLAS_TRANSACTIONAL_SENDER_DOMAIN` is the verified sender domain used in `/v3/domains/{domain_name}/messages/send`. +- `NYLAS_TRACKING_HOSTNAME` is the custom hostname serialized as `tracking_options.domain_name`. + +## Setup + +Install the SDK from the repository root and set the shared environment variables: + +```bash +pip install -e . + +export NYLAS_API_KEY="your_api_key" +export RECIPIENT_EMAIL="recipient@example.com" +export NYLAS_TRACKING_HOSTNAME="tracking.example.com" +``` + +For regular, draft, and scheduled grant-based operations, also set: + +```bash +export NYLAS_GRANT_ID="your_grant_id" +``` + +For Transactional Send, use a verified sender domain and an address on that domain: + +```bash +export NYLAS_TRANSACTIONAL_SENDER_DOMAIN="sender.example.com" +export SENDER_EMAIL="support@sender.example.com" +``` + +## Run an operation + +The default operation is `regular`. Set `NYLAS_CUSTOM_TRACKING_OPERATION` to choose another: + +```bash +NYLAS_CUSTOM_TRACKING_OPERATION=regular \ + python examples/custom_tracking_domain_demo/custom_tracking_domain_example.py + +NYLAS_CUSTOM_TRACKING_OPERATION=draft \ + python examples/custom_tracking_domain_demo/custom_tracking_domain_example.py + +NYLAS_CUSTOM_TRACKING_OPERATION=scheduled \ +NYLAS_SEND_AT=1893456000 \ + python examples/custom_tracking_domain_demo/custom_tracking_domain_example.py + +NYLAS_CUSTOM_TRACKING_OPERATION=transactional \ + python examples/custom_tracking_domain_demo/custom_tracking_domain_example.py +``` + +`NYLAS_SEND_AT` is a Unix timestamp. Scheduled messages validate the custom tracking hostname when the schedule is created and again before delivery. diff --git a/examples/custom_tracking_domain_demo/custom_tracking_domain_example.py b/examples/custom_tracking_domain_demo/custom_tracking_domain_example.py new file mode 100644 index 0000000..89a8840 --- /dev/null +++ b/examples/custom_tracking_domain_demo/custom_tracking_domain_example.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Use an organization-owned custom hostname for email tracking. + +Choose one operation with ``NYLAS_CUSTOM_TRACKING_OPERATION``: +``regular``, ``draft``, ``scheduled``, or ``transactional``. +""" + +import os +import sys +from typing import Optional + +from nylas import Client +from nylas.models.drafts import ( + CreateDraftRequest, + SendMessageRequest, + TrackingOptions, +) +from nylas.models.transactional_send import TransactionalSendMessageRequest + + +SUPPORTED_OPERATIONS = {"regular", "draft", "scheduled", "transactional"} + + +def get_env_or_exit(name: str) -> str: + """Return a required environment variable or exit.""" + value = os.getenv(name) + if not value: + print(f"Error: {name} environment variable is required") + sys.exit(1) + return value + + +def build_tracking_options(tracking_hostname: str) -> TrackingOptions: + """Enable link and open tracking on the selected custom hostname.""" + return { + "links": True, + "opens": True, + "domain_name": tracking_hostname, + } + + +def send_regular_message( + client: Client, grant_id: str, recipient: str, tracking_hostname: str +) -> None: + """Send a grant-based message immediately.""" + request_body: SendMessageRequest = { + "subject": "Tracked update", + "to": [{"email": recipient}], + "body": 'Open example', + "tracking_options": build_tracking_options(tracking_hostname), + } + response = client.messages.send(identifier=grant_id, request_body=request_body) + print(f"Sent message: {response.data.id}") + + +def create_tracked_draft( + client: Client, grant_id: str, recipient: str, tracking_hostname: str +) -> None: + """Create a draft that uses the custom tracking hostname.""" + request_body: CreateDraftRequest = { + "subject": "Tracked draft", + "to": [{"email": recipient}], + "body": 'Open example', + "tracking_options": build_tracking_options(tracking_hostname), + } + response = client.drafts.create(identifier=grant_id, request_body=request_body) + print(f"Created draft: {response.data.id}") + + +def schedule_tracked_message( + client: Client, + grant_id: str, + recipient: str, + tracking_hostname: str, + send_at: int, +) -> None: + """Schedule a grant-based message with the custom tracking hostname.""" + request_body: SendMessageRequest = { + "subject": "Scheduled tracked update", + "to": [{"email": recipient}], + "body": 'Open example', + "send_at": send_at, + "tracking_options": build_tracking_options(tracking_hostname), + } + response = client.messages.send(identifier=grant_id, request_body=request_body) + print(f"Scheduled message: {response.data.schedule_id}") + + +def send_transactional_message( + client: Client, + sender_domain: str, + sender_email: str, + recipient: str, + tracking_hostname: str, +) -> None: + """Send from one verified domain while tracking on a separate hostname.""" + request_body: TransactionalSendMessageRequest = { + "subject": "Transactional tracked update", + "to": [{"email": recipient}], + "from_": {"email": sender_email}, + "body": 'Open example', + "tracking_options": build_tracking_options(tracking_hostname), + } + response = client.transactional_send.send( + # This route value is the verified sender domain, not the tracking hostname. + domain_name=sender_domain, + request_body=request_body, + ) + print(f"Sent transactional message: {response.data.id}") + + +def parse_send_at(value: Optional[str]) -> int: + """Parse the scheduled send timestamp.""" + if not value: + print("Error: NYLAS_SEND_AT is required for the scheduled operation") + sys.exit(1) + try: + return int(value) + except ValueError: + print("Error: NYLAS_SEND_AT must be a Unix timestamp") + sys.exit(1) + + +def main() -> None: + """Run one custom tracking hostname example.""" + operation = os.getenv("NYLAS_CUSTOM_TRACKING_OPERATION", "regular") + if operation not in SUPPORTED_OPERATIONS: + supported = ", ".join(sorted(SUPPORTED_OPERATIONS)) + print(f"Error: unsupported operation {operation!r}; choose one of {supported}") + sys.exit(1) + + client = Client(api_key=get_env_or_exit("NYLAS_API_KEY")) + recipient = get_env_or_exit("RECIPIENT_EMAIL") + tracking_hostname = get_env_or_exit("NYLAS_TRACKING_HOSTNAME") + + if operation == "transactional": + send_transactional_message( + client=client, + sender_domain=get_env_or_exit("NYLAS_TRANSACTIONAL_SENDER_DOMAIN"), + sender_email=get_env_or_exit("SENDER_EMAIL"), + recipient=recipient, + tracking_hostname=tracking_hostname, + ) + return + + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + if operation == "regular": + send_regular_message(client, grant_id, recipient, tracking_hostname) + elif operation == "draft": + create_tracked_draft(client, grant_id, recipient, tracking_hostname) + else: + schedule_tracked_message( + client, + grant_id, + recipient, + tracking_hostname, + parse_send_at(os.getenv("NYLAS_SEND_AT")), + ) + + +if __name__ == "__main__": + main() diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index 78626b9..da31b7d 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -49,12 +49,14 @@ class TrackingOptions(TypedDict): links: Whether to track links. opens: Whether to track opens. thread_replies: Whether to track thread replies. + domain_name: The custom hostname used for link and open tracking. """ label: NotRequired[str] links: NotRequired[bool] opens: NotRequired[bool] thread_replies: NotRequired[bool] + domain_name: NotRequired[str] class CustomHeader(TypedDict): @@ -85,7 +87,8 @@ class CreateDraftRequest(TypedDict): attachments: The attachments on the message. send_at: Unix timestamp to send the message at. reply_to_message_id: The ID of the message that you are replying to. - tracking_options: Options for tracking opens, links, and thread replies. + tracking_options: Options for tracking opens, links, thread replies, and an + optional custom hostname. custom_headers: Custom headers to add to the message. metadata: A dictionary of key-value pairs storing additional data. is_plaintext: When true, the message body is sent as plain text and the MIME data doesn't include @@ -181,7 +184,8 @@ class SendMessageRequest(CreateDraftRequest): attachments (NotRequired[List[CreateAttachmentRequest]]): The attachments on the message. send_at (NotRequired[int]): Unix timestamp to send the message at. reply_to_message_id (NotRequired[str]): The ID of the message that you are replying to. - tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, links, and thread replies. + tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, + links, thread replies, and an optional custom hostname. custom_headers(NotRequired[List[CustomHeader]]): Custom headers to add to the message. is_plaintext (NotRequired[bool]): When true, the message body is sent as plain text and the MIME data doesn't include the HTML version of the message. When false, the message body is sent as HTML. diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 9caf5bf..beae725 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -38,12 +38,16 @@ class TrackingOptions: thread_replies: When true, shows that thread replied tracking is enabled. links: When true, shows that link clicked tracking is enabled. label: A label describing the message tracking purpose. + domain_name: The custom hostname used for link and open tracking. """ opens: Optional[bool] = None thread_replies: Optional[bool] = None links: Optional[bool] = None label: Optional[str] = None + domain_name: Optional[str] = field( + default=None, metadata=config(exclude=lambda value: value is None) + ) @dataclass_json diff --git a/nylas/models/transactional_send.py b/nylas/models/transactional_send.py index b9b4a81..bd9287b 100644 --- a/nylas/models/transactional_send.py +++ b/nylas/models/transactional_send.py @@ -39,7 +39,8 @@ class TransactionalSendMessageRequest(TypedDict, total=False): 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. + tracking_options: Open/link tracking settings, including an optional custom + tracking hostname. custom_headers: Custom MIME headers. metadata: String-keyed metadata. is_plaintext: Send body as plain text when true. diff --git a/nylas/resources/transactional_send.py b/nylas/resources/transactional_send.py index e6eae0e..41c0fb4 100644 --- a/nylas/resources/transactional_send.py +++ b/nylas/resources/transactional_send.py @@ -30,8 +30,10 @@ def send( Send a transactional email from the specified domain. Args: - domain_name: The domain Nylas sends from (must be verified in the dashboard). + domain_name: The sender domain Nylas sends from (must be verified in the dashboard). request_body: Message fields; use ``from_`` for the sender (maps to JSON ``from``). + A separate ``tracking_options.domain_name`` value selects the custom + tracking hostname. overrides: Per-request overrides for the HTTP client. Returns: diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index a0d3dbe..101cece 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -144,6 +144,55 @@ def test_create_draft(self, http_client_response): overrides=None, ) + def test_create_draft_with_custom_tracking_hostname(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Tracked draft", + "to": [{"email": "recipient@example.com"}], + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + "domain_name": "tracking.example.com", + }, + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + + def test_create_draft_without_custom_tracking_hostname_unchanged( + self, http_client_response + ): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Tracked draft", + "to": [{"email": "recipient@example.com"}], + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + }, + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + def test_create_draft_with_metadata(self, http_client_response): drafts = Drafts(http_client_response) request_body = { @@ -245,6 +294,29 @@ def test_update_draft(self, http_client_response): overrides=None, ) + def test_update_draft_with_custom_tracking_hostname(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "tracking_options": { + "links": True, + "opens": True, + "domain_name": "replacement-tracking.example.com", + }, + } + + drafts.update( + identifier="abc-123", draft_id="draft-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/drafts/draft-123", + None, + None, + request_body, + overrides=None, + ) + def test_update_draft_encoded_id(self, http_client_response): drafts = Drafts(http_client_response) request_body = { diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 15493c7..5d765b9 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -245,6 +245,79 @@ def test_send_message(self, http_client_response): overrides=None, ) + def test_send_message_with_custom_tracking_hostname(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"email": "recipient@example.com"}], + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + "domain_name": "tracking.example.com", + }, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_without_custom_tracking_hostname_unchanged( + self, http_client_response + ): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"email": "recipient@example.com"}], + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + }, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_scheduled_message_with_custom_tracking_hostname( + self, http_client_response + ): + messages = Messages(http_client_response) + request_body = { + "subject": "Scheduled update", + "to": [{"email": "recipient@example.com"}], + "body": 'Open example', + "send_at": 1893456000, + "tracking_options": { + "links": True, + "opens": True, + "domain_name": "tracking.example.com", + }, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + def test_send_message_small_attachment(self, http_client_response): messages = Messages(http_client_response) request_body = { @@ -747,8 +820,9 @@ def test_message_deserialization_with_tracking_options(self): "opens": True, "thread_replies": False, "links": True, - "label": "Marketing Campaign" - } + "label": "Marketing Campaign", + "domain_name": "tracking.example.com", + }, } message = Message.from_dict(message_json) @@ -758,6 +832,7 @@ def test_message_deserialization_with_tracking_options(self): assert message.tracking_options.thread_replies is False assert message.tracking_options.links is True assert message.tracking_options.label == "Marketing Campaign" + assert message.tracking_options.domain_name == "tracking.example.com" def test_message_deserialization_with_raw_mime(self): """Test deserialization of message with raw_mime field.""" @@ -874,7 +949,8 @@ def test_tracking_options_serialization(self): opens=True, thread_replies=False, links=True, - label="Test Campaign" + label="Test Campaign", + domain_name="tracking.example.com", ) # Test serialization @@ -883,6 +959,7 @@ def test_tracking_options_serialization(self): assert json_data["thread_replies"] is False assert json_data["links"] is True assert json_data["label"] == "Test Campaign" + assert json_data["domain_name"] == "tracking.example.com" # Test deserialization tracking_options_from_dict = TrackingOptions.from_dict(json_data) @@ -890,6 +967,25 @@ def test_tracking_options_serialization(self): assert tracking_options_from_dict.thread_replies is False assert tracking_options_from_dict.links is True assert tracking_options_from_dict.label == "Test Campaign" + assert tracking_options_from_dict.domain_name == "tracking.example.com" + + def test_tracking_options_omit_unset_domain_name(self): + """Test that existing tracking options do not gain a null wire field.""" + from nylas.models.messages import TrackingOptions + + tracking_options = TrackingOptions( + opens=True, + thread_replies=False, + links=True, + label="Test Campaign", + ) + + assert tracking_options.to_dict() == { + "opens": True, + "thread_replies": False, + "links": True, + "label": "Test Campaign", + } def test_send_message_with_is_plaintext_true(self, http_client_response): """Test sending a message with is_plaintext=True.""" @@ -1134,4 +1230,4 @@ def test_send_message_with_special_characters_large_attachment(self, http_client request_body=None, data=mock_encoder, overrides=None, - ) \ No newline at end of file + ) diff --git a/tests/resources/test_transactional_send.py b/tests/resources/test_transactional_send.py index a4843da..57de152 100644 --- a/tests/resources/test_transactional_send.py +++ b/tests/resources/test_transactional_send.py @@ -28,6 +28,80 @@ def test_send_transactional_message(self, http_client_response): overrides=None, ) + def test_send_with_custom_tracking_hostname(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "subject": "Welcome", + "to": [{"email": "recipient@example.com"}], + "from_": {"email": "support@sender.example.com"}, + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + "domain_name": "tracking.example.com", + }, + } + + transactional_send.send( + domain_name="sender.example.com", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/sender.example.com/messages/send", + request_body={ + "subject": "Welcome", + "to": [{"email": "recipient@example.com"}], + "from": {"email": "support@sender.example.com"}, + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + "domain_name": "tracking.example.com", + }, + }, + data=None, + overrides=None, + ) + + def test_send_without_custom_tracking_hostname_unchanged( + self, http_client_response + ): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "subject": "Welcome", + "to": [{"email": "recipient@example.com"}], + "from_": {"email": "support@sender.example.com"}, + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + }, + } + + transactional_send.send( + domain_name="sender.example.com", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/sender.example.com/messages/send", + request_body={ + "subject": "Welcome", + "to": [{"email": "recipient@example.com"}], + "from": {"email": "support@sender.example.com"}, + "body": 'Open example', + "tracking_options": { + "links": True, + "opens": True, + }, + }, + data=None, + overrides=None, + ) + def test_send_domain_name_url_encoded(self, http_client_response): transactional_send = TransactionalSend(http_client_response) request_body = {