From c1c439a38159e496ae25f68b7d4b8dcef5d22929 Mon Sep 17 00:00:00 2001 From: Zhi Qu Date: Mon, 3 Aug 2026 12:58:12 -0700 Subject: [PATCH] EN-144: add custom tracking domain support --- CHANGELOG.md | 5 + examples/.env.example | 10 +- examples/README.md | 28 ++- examples/build.gradle.kts | 3 +- .../examples/CustomTrackingDomainExample.java | 165 ++++++++++++++++++ .../com/nylas/models/TrackingOptions.kt | 62 ++++++- .../com/nylas/models/TrackingOptionsTests.kt | 56 ++++++ .../com/nylas/resources/DomainsTests.kt | 3 +- .../com/nylas/resources/MessagesTests.kt | 4 +- 9 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 examples/src/main/java/com/nylas/examples/CustomTrackingDomainExample.java create mode 100644 src/test/kotlin/com/nylas/models/TrackingOptionsTests.kt 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