From 52b30cd44fe7436647657e1b21d945ddda34d5c5 Mon Sep 17 00:00:00 2001 From: Zhi Qu Date: Thu, 30 Jul 2026 12:19:04 -0700 Subject: [PATCH] feat(messages): add custom tracking domain support --- CHANGELOG.md | 3 + examples/messages/ENVIRONMENT.md | 7 ++- examples/messages/README.md | 95 +++++++++++++++++++++++++++++++- examples/messages/messages.ts | 11 +++- src/models/drafts.ts | 5 ++ tests/apiClient.spec.ts | 46 ++++++++++++++++ 6 files changed, 164 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac005d6f..5d1405d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Add optional `trackingOptions.domainName` support for custom link and open tracking hostnames + ### Changed - Clarify that event `default` visibility is Google-only diff --git a/examples/messages/ENVIRONMENT.md b/examples/messages/ENVIRONMENT.md index afd6f8ef..0d5834c1 100644 --- a/examples/messages/ENVIRONMENT.md +++ b/examples/messages/ENVIRONMENT.md @@ -14,6 +14,9 @@ NYLAS_API_URI=https://api.us.nylas.com # For testing message sending (optional) TEST_EMAIL=your-test-email@example.com + +# Active custom hostname owned by your organization (optional) +NYLAS_TRACKING_DOMAIN=links.example.com ``` ## Getting Your API Key and Grant ID @@ -25,6 +28,8 @@ TEST_EMAIL=your-test-email@example.com If you want to test the message sending functionality, set the `TEST_EMAIL` environment variable to an email address you control. The example will skip message sending if this variable is not set. +Set `NYLAS_TRACKING_DOMAIN` to an active custom hostname registered to your organization to use it for link and open tracking. If it is omitted, tracked messages use the default Nylas tracking hostname. + ## Running the Example Once your environment variables are set: @@ -42,4 +47,4 @@ npm run messages - **Raw MIME**: Getting raw MIME data for messages - **Message Operations**: Listing, finding, updating, and sending messages - **Scheduled Messages**: Working with scheduled message functionality -- **Message Cleaning**: Using the clean messages API \ No newline at end of file +- **Message Cleaning**: Using the clean messages API diff --git a/examples/messages/README.md b/examples/messages/README.md index 3dde23cf..8c9403d9 100644 --- a/examples/messages/README.md +++ b/examples/messages/README.md @@ -21,6 +21,99 @@ The file is structured with: ### Basic Messages (`messages.ts`) Shows basic message operations including reading, sending, and drafting messages. +### Custom tracking hostnames + +Set `trackingOptions.domainName` to an active custom hostname owned by your +organization. At least one of `links` or `opens` must be enabled. Omit +`domainName` to keep using the default Nylas tracking hostname. + +#### Regular Send + +```ts +await nylas.messages.send({ + identifier: grantId, + requestBody: { + to: [{ email: 'recipient@example.com' }], + subject: 'Your update', + body: 'View update', + trackingOptions: { + links: true, + opens: true, + domainName: 'links.example.com', + }, + }, +}); +``` + +#### Transactional Send + +The top-level `domainName` is the verified sender domain used by the route. The +nested `trackingOptions.domainName` is the tracking hostname used in recipient- +visible links and open pixels. + +```ts +await nylas.transactionalSend.send({ + domainName: 'sender.example.com', + requestBody: { + from: [{ email: 'billing@sender.example.com' }], + to: [{ email: 'recipient@example.com' }], + subject: 'Your receipt', + body: 'View receipt', + trackingOptions: { + links: true, + opens: true, + domainName: 'links.example.com', + }, + }, +}); +``` + +#### Drafts + +```ts +const draft = await nylas.drafts.create({ + identifier: grantId, + requestBody: { + to: [{ email: 'recipient@example.com' }], + subject: 'Draft update', + body: 'View update', + trackingOptions: { + links: true, + opens: true, + domainName: 'links.example.com', + }, + }, +}); + +await nylas.drafts.update({ + identifier: grantId, + draftId: draft.data.id, + requestBody: { + subject: 'Updated draft subject', + // Omit trackingOptions to preserve the draft's existing tracking settings. + }, +}); +``` + +#### Scheduled Send + +```ts +await nylas.messages.send({ + identifier: grantId, + requestBody: { + to: [{ email: 'recipient@example.com' }], + subject: 'Scheduled update', + body: 'View update', + sendAt: Math.floor(Date.now() / 1000) + 3600, + trackingOptions: { + links: true, + opens: true, + domainName: 'links.example.com', + }, + }, +}); +``` + ### Accessing Rate Limit Headers All SDK responses now expose a non-enumerable `rawHeaders` with dashed lowercase keys so you can read rate limit information: @@ -181,4 +274,4 @@ npm run send-attachments status # Check file availability **TypeScript import errors:** - Ensure you've run `npm install` in the examples directory -- The utils are properly exported from the attachment-file-manager module \ No newline at end of file +- The utils are properly exported from the attachment-file-manager module diff --git a/examples/messages/messages.ts b/examples/messages/messages.ts index f1f6c946..a64373ee 100644 --- a/examples/messages/messages.ts +++ b/examples/messages/messages.ts @@ -17,6 +17,8 @@ dotenv.config({ path: path.resolve(__dirname, '../.env') }); // Check for required environment variables const apiKey: string = process.env.NYLAS_API_KEY || ''; const grantId: string = process.env.NYLAS_GRANT_ID || ''; +const trackingDomain: string | undefined = + process.env.NYLAS_TRACKING_DOMAIN || undefined; if (!apiKey) { throw new Error('NYLAS_API_KEY environment variable is not set'); @@ -244,10 +246,17 @@ async function demonstrateMessageSending(): Promise | nul `, - // Note: Tracking options are configured at the API/provider level, not in the request + trackingOptions: { + links: true, + opens: true, + ...(trackingDomain ? { domainName: trackingDomain } : {}), + }, }; console.log('Sending message with tracking...'); + if (trackingDomain) { + console.log(`Using custom tracking hostname: ${trackingDomain}`); + } const sentMessage = await nylas.messages.send({ identifier: grantId, requestBody, diff --git a/src/models/drafts.ts b/src/models/drafts.ts index 893be130..e978f82c 100644 --- a/src/models/drafts.ts +++ b/src/models/drafts.ts @@ -125,6 +125,11 @@ export interface TrackingOptions { links?: boolean; opens?: boolean; threadReplies?: boolean; + /** + * The custom hostname to use for link and open tracking. + * The hostname must be active and owned by the authenticated organization. + */ + domainName?: string; } /** diff --git a/tests/apiClient.spec.ts b/tests/apiClient.spec.ts index 7873b825..4fe37a2a 100644 --- a/tests/apiClient.spec.ts +++ b/tests/apiClient.spec.ts @@ -161,6 +161,52 @@ describe('APIClient', () => { expect(options.body).toEqual(expectedBody); }); + + it('should serialize a custom tracking hostname as domain_name exactly once', () => { + const options = client.requestOptions({ + path: '/test', + method: 'POST', + body: { + trackingOptions: { + links: true, + opens: true, + domainName: 'links.example.com', + }, + }, + }); + + const serializedBody = options.body as string; + expect(JSON.parse(serializedBody)).toEqual({ + tracking_options: { + links: true, + opens: true, + domain_name: 'links.example.com', + }, + }); + expect(serializedBody.match(/"domain_name"/g)).toHaveLength(1); + expect(serializedBody).not.toContain('domainName'); + }); + + it('should leave tracking options unchanged when domainName is omitted', () => { + const options = client.requestOptions({ + path: '/test', + method: 'POST', + body: { + trackingOptions: { + links: true, + opens: false, + }, + }, + }); + + expect(JSON.parse(options.body as string)).toEqual({ + tracking_options: { + links: true, + opens: false, + }, + }); + expect(options.body as string).not.toContain('domain_name'); + }); }); describe('newRequest', () => {