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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
61 changes: 61 additions & 0 deletions examples/custom_tracking_domain_demo/README.md
Original file line number Diff line number Diff line change
@@ -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.
162 changes: 162 additions & 0 deletions examples/custom_tracking_domain_demo/custom_tracking_domain_example.py
Original file line number Diff line number Diff line change
@@ -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": '<a href="https://example.com">Open example</a>',
"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": '<a href="https://example.com">Open example</a>',
"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": '<a href="https://example.com">Open example</a>',
"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": '<a href="https://example.com">Open example</a>',
"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()
8 changes: 6 additions & 2 deletions nylas/models/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions nylas/models/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion nylas/models/transactional_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion nylas/resources/transactional_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions tests/resources/test_drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": '<a href="https://example.com">Open example</a>',
"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": '<a href="https://example.com">Open example</a>',
"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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading