diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0243470b..d6fbdb78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Nylas Java SDK Changelog
+## Unreleased
+
+### Added
+* Optional `TrackingOptions.domainName` support for custom link and open tracking hostnames in regular sends, Transactional Send, drafts, and scheduled sends. The field serializes as `tracking_options.domain_name` and is omitted when unset.
+
## [v2.18.0] - Release 2026-07-10
### Added
diff --git a/examples/.env.example b/examples/.env.example
index 7d6d0112..fc2533cd 100644
--- a/examples/.env.example
+++ b/examples/.env.example
@@ -15,4 +15,12 @@ NYLAS_API_URI=https://api.us.nylas.com
NYLAS_GRANT_ID=your_grant_id_here
# Test email
-NYLAS_TEST_EMAIL=your@email.com
\ No newline at end of file
+NYLAS_TEST_EMAIL=your@email.com
+
+# Custom tracking hostname example
+RECIPIENT_EMAIL=recipient@example.com
+NYLAS_TRACKING_HOSTNAME=tracking.example.com
+
+# Transactional Send operation only: the verified sender domain is separate from the tracking hostname
+NYLAS_TRANSACTIONAL_SENDER_DOMAIN=sender.example.com
+SENDER_EMAIL=support@sender.example.com
diff --git a/examples/README.md b/examples/README.md
index 7818a3ad..09090c21 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -51,6 +51,19 @@ The `LargeAttachmentsExample` demonstrates how to send emails with large file at
- Use the `FileUtils.attachFileRequestBuilder()` helper method for easy file attachment
- Automatic cleanup of temporary test files
+### Custom Tracking Hostname Example
+
+`CustomTrackingDomainExample` demonstrates the optional `TrackingOptions.domainName` field for:
+
+- regular grant-based sends;
+- draft creation;
+- scheduled grant-based sends; and
+- Transactional Send.
+
+The hostname must be active and owned by the authenticated organization. Enable link tracking, open tracking, or both when setting it. Omit `domainName` to keep using the default Nylas tracking hostname.
+
+For Transactional Send, the values are intentionally distinct: `NYLAS_TRANSACTIONAL_SENDER_DOMAIN` is the verified sender domain used in `/v3/domains/{domain_name}/messages/send`, while `NYLAS_TRACKING_HOSTNAME` becomes `tracking_options.domain_name` in the request body.
+
## Setup
### 1. Environment Setup
@@ -72,6 +85,10 @@ NYLAS_GRANT_ID=your_grant_id_here
# Test email address (required for large attachments example)
NYLAS_TEST_EMAIL=test@example.com
+# An active custom hostname owned by your organization (custom tracking example)
+RECIPIENT_EMAIL=recipient@example.com
+NYLAS_TRACKING_HOSTNAME=tracking.example.com
+
# Add your meeting link (Zoom, Google Meet, or Microsoft Teams) - for Notetaker example
MEETING_LINK=your_meeting_link_here
```
@@ -120,6 +137,14 @@ Run Java Large Attachments example:
./gradlew :examples:run -PmainClass=com.nylas.examples.LargeAttachmentsExample
```
+Run the custom tracking hostname example (defaults to a regular send):
+```bash
+NYLAS_CUSTOM_TRACKING_OPERATION=regular \
+ ./gradlew :examples:run -PmainClass=com.nylas.examples.CustomTrackingDomainExample
+```
+
+Choose `draft`, `scheduled`, or `transactional` for the other operations. Scheduled sends also require `NYLAS_SEND_AT` as a Unix timestamp. Transactional Send requires `NYLAS_TRANSACTIONAL_SENDER_DOMAIN` and `SENDER_EMAIL`; the non-transactional operations require `NYLAS_GRANT_ID`.
+
#### Option 2: Using the Makefile
List available examples:
@@ -147,6 +172,7 @@ make kotlin-way
- `EventsExample.java` (Java - demonstrates events)
- `FoldersExample.java` (Java - demonstrates folders and single_level parameter)
- `LargeAttachmentsExample.java` (Java - demonstrates large file attachments)
+ - `CustomTrackingDomainExample.java` (Java - demonstrates custom link and open tracking hostnames)
- `NotetakerExample.java` (Java - demonstrates notetakers)
- `KotlinNotetakerExample.kt` (Kotlin - demonstrates notetakers)
- `KotlinFoldersExample.kt` (Kotlin - demonstrates folders and single_level parameter)
@@ -193,4 +219,4 @@ The Messages examples showcase the following new features added to the Nylas SDK
## Additional Information
-For more information about the Nylas API, refer to the [Nylas API documentation](https://developer.nylas.com/).
\ No newline at end of file
+For more information about the Nylas API, refer to the [Nylas API documentation](https://developer.nylas.com/).
diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts
index 3c637bbf..f26bd07e 100644
--- a/examples/build.gradle.kts
+++ b/examples/build.gradle.kts
@@ -56,6 +56,7 @@ tasks.register("listExamples") {
println("- Java-Messages: com.nylas.examples.MessagesExample")
println("- Java-Folders: com.nylas.examples.FoldersExample")
println("- Java-Large-Attachments: com.nylas.examples.LargeAttachmentsExample")
+ println("- Java-Custom-Tracking-Domain: com.nylas.examples.CustomTrackingDomainExample")
println("- Kotlin-Notetaker: com.nylas.examples.KotlinNotetakerExampleKt")
println("- Kotlin-Messages: com.nylas.examples.KotlinMessagesExampleKt")
println("- Kotlin-Folders: com.nylas.examples.KotlinFoldersExampleKt")
@@ -73,4 +74,4 @@ sourceSets {
srcDir("src/main/kotlin")
}
}
-}
\ No newline at end of file
+}
diff --git a/examples/src/main/java/com/nylas/examples/CustomTrackingDomainExample.java b/examples/src/main/java/com/nylas/examples/CustomTrackingDomainExample.java
new file mode 100644
index 00000000..d0fd06f9
--- /dev/null
+++ b/examples/src/main/java/com/nylas/examples/CustomTrackingDomainExample.java
@@ -0,0 +1,165 @@
+package com.nylas.examples;
+
+import com.nylas.NylasClient;
+import com.nylas.models.CreateDraftRequest;
+import com.nylas.models.Draft;
+import com.nylas.models.EmailName;
+import com.nylas.models.Message;
+import com.nylas.models.NylasApiError;
+import com.nylas.models.NylasSdkTimeoutError;
+import com.nylas.models.Response;
+import com.nylas.models.SendMessageRequest;
+import com.nylas.models.SendTransactionalEmailRequest;
+import com.nylas.models.TrackingOptions;
+import okhttp3.OkHttpClient;
+
+import java.util.Collections;
+
+/**
+ * Demonstrates custom tracking hostnames for regular, draft, scheduled, and Transactional Send requests.
+ */
+public class CustomTrackingDomainExample {
+ private static final String LINK_BODY = "Open example";
+
+ public static void main(String[] args) throws NylasApiError, NylasSdkTimeoutError {
+ String operation = System.getenv().getOrDefault("NYLAS_CUSTOM_TRACKING_OPERATION", "regular");
+ String apiKey = requireEnvironmentVariable("NYLAS_API_KEY");
+ String recipientEmail = requireEnvironmentVariable("RECIPIENT_EMAIL");
+ String trackingHostname = requireEnvironmentVariable("NYLAS_TRACKING_HOSTNAME");
+ String apiUri = System.getenv().getOrDefault("NYLAS_API_URI", "https://api.us.nylas.com");
+
+ NylasClient nylas = new NylasClient(apiKey, new OkHttpClient.Builder(), apiUri);
+
+ switch (operation) {
+ case "regular":
+ sendRegularMessage(
+ nylas,
+ requireEnvironmentVariable("NYLAS_GRANT_ID"),
+ recipientEmail,
+ trackingHostname);
+ break;
+ case "draft":
+ createTrackedDraft(
+ nylas,
+ requireEnvironmentVariable("NYLAS_GRANT_ID"),
+ recipientEmail,
+ trackingHostname);
+ break;
+ case "scheduled":
+ scheduleTrackedMessage(
+ nylas,
+ requireEnvironmentVariable("NYLAS_GRANT_ID"),
+ recipientEmail,
+ trackingHostname,
+ requireSendAt());
+ break;
+ case "transactional":
+ sendTransactionalMessage(
+ nylas,
+ requireEnvironmentVariable("NYLAS_TRANSACTIONAL_SENDER_DOMAIN"),
+ requireEnvironmentVariable("SENDER_EMAIL"),
+ recipientEmail,
+ trackingHostname);
+ break;
+ default:
+ throw new IllegalArgumentException(
+ "NYLAS_CUSTOM_TRACKING_OPERATION must be regular, draft, scheduled, or transactional");
+ }
+ }
+
+ private static TrackingOptions buildTrackingOptions(String trackingHostname) {
+ return new TrackingOptions.Builder()
+ .links(true)
+ .opens(true)
+ .domainName(trackingHostname)
+ .build();
+ }
+
+ private static void sendRegularMessage(
+ NylasClient nylas, String grantId, String recipientEmail, String trackingHostname)
+ throws NylasApiError, NylasSdkTimeoutError {
+ SendMessageRequest request = new SendMessageRequest.Builder(
+ Collections.singletonList(new EmailName(recipientEmail, "Recipient")))
+ .subject("Tracked update")
+ .body(LINK_BODY)
+ .trackingOptions(buildTrackingOptions(trackingHostname))
+ .build();
+
+ Response response = nylas.messages().send(grantId, request);
+ System.out.println("Sent message: " + response.getData().getId());
+ }
+
+ private static void createTrackedDraft(
+ NylasClient nylas, String grantId, String recipientEmail, String trackingHostname)
+ throws NylasApiError, NylasSdkTimeoutError {
+ CreateDraftRequest request = new CreateDraftRequest.Builder()
+ .to(Collections.singletonList(new EmailName(recipientEmail, "Recipient")))
+ .subject("Tracked draft")
+ .body(LINK_BODY)
+ .trackingOptions(buildTrackingOptions(trackingHostname))
+ .build();
+
+ Response response = nylas.drafts().create(grantId, request);
+ System.out.println("Created draft: " + response.getData().getId());
+ }
+
+ private static void scheduleTrackedMessage(
+ NylasClient nylas,
+ String grantId,
+ String recipientEmail,
+ String trackingHostname,
+ long sendAt)
+ throws NylasApiError, NylasSdkTimeoutError {
+ SendMessageRequest request = new SendMessageRequest.Builder(
+ Collections.singletonList(new EmailName(recipientEmail, "Recipient")))
+ .subject("Scheduled tracked update")
+ .body(LINK_BODY)
+ .sendAt(sendAt)
+ .trackingOptions(buildTrackingOptions(trackingHostname))
+ .build();
+
+ Response response = nylas.messages().send(grantId, request);
+ System.out.println("Scheduled message: " + response.getData().getScheduleId());
+ }
+
+ private static void sendTransactionalMessage(
+ NylasClient nylas,
+ String senderDomain,
+ String senderEmail,
+ String recipientEmail,
+ String trackingHostname)
+ throws NylasApiError, NylasSdkTimeoutError {
+ SendTransactionalEmailRequest request = new SendTransactionalEmailRequest.Builder(
+ Collections.singletonList(new EmailName(recipientEmail, "Recipient")),
+ new EmailName(senderEmail, "Sender"))
+ .subject("Transactional tracked update")
+ .body(LINK_BODY)
+ .trackingOptions(buildTrackingOptions(trackingHostname))
+ .build();
+
+ // The route value is the verified sender domain. The nested domainName is the tracking hostname.
+ Response response = nylas.domains().sendTransactionalEmail(senderDomain, request);
+ System.out.println("Sent transactional message: " + response.getData().getId());
+ }
+
+ private static String requireEnvironmentVariable(String name) {
+ String value = System.getenv(name);
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " environment variable is required");
+ }
+ return value;
+ }
+
+ private static long requireSendAt() {
+ String value = requireEnvironmentVariable("NYLAS_SEND_AT");
+ try {
+ long sendAt = Long.parseLong(value);
+ if (sendAt <= 0) {
+ throw new IllegalArgumentException("NYLAS_SEND_AT must be a positive Unix timestamp");
+ }
+ return sendAt;
+ } catch (NumberFormatException error) {
+ throw new IllegalArgumentException("NYLAS_SEND_AT must be a Unix timestamp", error);
+ }
+ }
+}
diff --git a/src/main/kotlin/com/nylas/models/TrackingOptions.kt b/src/main/kotlin/com/nylas/models/TrackingOptions.kt
index aaaa3a17..77cbe7e5 100644
--- a/src/main/kotlin/com/nylas/models/TrackingOptions.kt
+++ b/src/main/kotlin/com/nylas/models/TrackingOptions.kt
@@ -5,7 +5,7 @@ import com.squareup.moshi.Json
/**
* Class representing the different tracking options for when a message is sent.
*/
-data class TrackingOptions(
+data class TrackingOptions @JvmOverloads constructor(
/**
* The label to apply to tracked messages.
*/
@@ -26,4 +26,62 @@ data class TrackingOptions(
*/
@Json(name = "thread_replies")
val threadReplies: Boolean? = null,
-)
+ /**
+ * The custom hostname to use for link and open tracking.
+ * The hostname must be active and owned by the authenticated organization.
+ */
+ @Json(name = "domain_name")
+ val domainName: String? = null,
+) {
+ /**
+ * Builder for [TrackingOptions].
+ */
+ class Builder {
+ private var label: String? = null
+ private var links: Boolean? = null
+ private var opens: Boolean? = null
+ private var threadReplies: Boolean? = null
+ private var domainName: String? = null
+
+ /**
+ * Set the label to apply to tracked messages.
+ * @param label The tracking label.
+ * @return The builder.
+ */
+ fun label(label: String?) = apply { this.label = label }
+
+ /**
+ * Set whether to track links.
+ * @param links Whether to track links.
+ * @return The builder.
+ */
+ fun links(links: Boolean?) = apply { this.links = links }
+
+ /**
+ * Set whether to track opens.
+ * @param opens Whether to track opens.
+ * @return The builder.
+ */
+ fun opens(opens: Boolean?) = apply { this.opens = opens }
+
+ /**
+ * Set whether to track thread replies.
+ * @param threadReplies Whether to track thread replies.
+ * @return The builder.
+ */
+ fun threadReplies(threadReplies: Boolean?) = apply { this.threadReplies = threadReplies }
+
+ /**
+ * Set the custom hostname used for link and open tracking.
+ * @param domainName An active custom hostname owned by the authenticated organization.
+ * @return The builder.
+ */
+ fun domainName(domainName: String?) = apply { this.domainName = domainName }
+
+ /**
+ * Build the [TrackingOptions].
+ * @return The built [TrackingOptions].
+ */
+ fun build() = TrackingOptions(label, links, opens, threadReplies, domainName)
+ }
+}
diff --git a/src/test/kotlin/com/nylas/models/TrackingOptionsTests.kt b/src/test/kotlin/com/nylas/models/TrackingOptionsTests.kt
new file mode 100644
index 00000000..2bdb6acc
--- /dev/null
+++ b/src/test/kotlin/com/nylas/models/TrackingOptionsTests.kt
@@ -0,0 +1,56 @@
+package com.nylas.models
+
+import com.nylas.util.JsonHelper
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TrackingOptionsTests {
+ private val adapter = JsonHelper.moshi().adapter(TrackingOptions::class.java)
+
+ @Test
+ fun `builder exposes custom tracking hostname`() {
+ val options =
+ TrackingOptions.Builder()
+ .links(true)
+ .opens(true)
+ .domainName("tracking.example.com")
+ .build()
+
+ assertEquals("tracking.example.com", options.domainName)
+ }
+
+ @Test
+ fun `custom tracking hostname serializes as domain_name`() {
+ val options = TrackingOptions(links = true, opens = true, domainName = "tracking.example.com")
+
+ val json = adapter.toJson(options)
+
+ assertEquals("""{"links":true,"opens":true,"domain_name":"tracking.example.com"}""", json)
+ assertFalse(json.contains("domainName"))
+ }
+
+ @Test
+ fun `omitted custom tracking hostname leaves existing JSON unchanged`() {
+ val options = TrackingOptions(links = true, opens = false)
+
+ val json = adapter.toJson(options)
+
+ assertEquals("""{"links":true,"opens":false}""", json)
+ assertFalse(json.contains("domain_name"))
+ }
+
+ @Test
+ fun `send request nests custom hostname under tracking_options`() {
+ val request =
+ SendMessageRequest.Builder(listOf(EmailName("recipient@example.com")))
+ .trackingOptions(TrackingOptions(links = true, domainName = "tracking.example.com"))
+ .build()
+
+ val json = JsonHelper.moshi().adapter(SendMessageRequest::class.java).toJson(request)
+
+ assertTrue(json.contains(""""tracking_options":{"links":true,"domain_name":"tracking.example.com"}"""))
+ assertFalse(json.contains("domainName"))
+ }
+}
diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt
index 21969111..9ad3650f 100644
--- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt
+++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt
@@ -63,7 +63,7 @@ class DomainsTests {
"body": "Welcome! We're here to help.",
"send_at": 1620000000,
"reply_to_message_id": "msg-123",
- "tracking_options": {"opens": true, "links": true, "thread_replies": false, "label": "welcome"},
+ "tracking_options": {"opens": true, "links": true, "thread_replies": false, "label": "welcome", "domain_name": "tracking.example.com"},
"use_draft": false,
"custom_headers": [{"name": "X-Custom", "value": "custom-value"}],
"is_plaintext": false
@@ -91,6 +91,7 @@ class DomainsTests {
assertEquals(true, request.trackingOptions?.links)
assertEquals(false, request.trackingOptions?.threadReplies)
assertEquals("welcome", request.trackingOptions?.label)
+ assertEquals("tracking.example.com", request.trackingOptions?.domainName)
assertEquals(false, request.useDraft)
assertEquals(1, request.customHeaders?.size)
assertEquals("X-Custom", request.customHeaders?.get(0)?.name)
diff --git a/src/test/kotlin/com/nylas/resources/MessagesTests.kt b/src/test/kotlin/com/nylas/resources/MessagesTests.kt
index 9135871a..6e4b72c9 100644
--- a/src/test/kotlin/com/nylas/resources/MessagesTests.kt
+++ b/src/test/kotlin/com/nylas/resources/MessagesTests.kt
@@ -145,7 +145,8 @@ class MessagesTests {
"opens": true,
"thread_replies": false,
"links": true,
- "label": "test-campaign"
+ "label": "test-campaign",
+ "domain_name": "tracking.example.com"
}
}
""".trimIndent(),
@@ -160,6 +161,7 @@ class MessagesTests {
assertEquals(false, message.trackingOptions?.threadReplies)
assertEquals(true, message.trackingOptions?.links)
assertEquals("test-campaign", message.trackingOptions?.label)
+ assertEquals("tracking.example.com", message.trackingOptions?.domainName)
}
@Test