From 44d61cd60ab41f088bbab2485450632900c00b12 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:13:46 -0400 Subject: [PATCH 01/13] TW-5371 Add admin API SDK resources --- CHANGELOG.md | 14 +- src/models/applicationDetails.ts | 141 ++++++++++++++- src/models/domains.ts | 239 +++++++++++++++++++++++++ src/models/index.ts | 2 + src/models/policies.ts | 8 +- src/models/redirectUri.ts | 20 ++- src/models/rules.ts | 8 +- src/models/workspaces.ts | 194 ++++++++++++++++++++ src/nylas.ts | 12 ++ src/resources/applications.ts | 32 +++- src/resources/domains.ts | 200 +++++++++++++++++++++ src/resources/redirectUris.ts | 8 +- src/resources/resource.ts | 25 +++ src/resources/workspaces.ts | 196 +++++++++++++++++++++ tests/resources/applications.spec.ts | 88 ++++++++++ tests/resources/domains.spec.ts | 254 +++++++++++++++++++++++++++ tests/resources/policies.spec.ts | 2 + tests/resources/redirectUris.spec.ts | 33 +++- tests/resources/rules.spec.ts | 130 ++++++++++++++ tests/resources/workspaces.spec.ts | 210 ++++++++++++++++++++++ 20 files changed, 1786 insertions(+), 30 deletions(-) create mode 100644 src/models/domains.ts create mode 100644 src/models/workspaces.ts create mode 100644 src/resources/domains.ts create mode 100644 src/resources/workspaces.ts create mode 100644 tests/resources/domains.spec.ts create mode 100644 tests/resources/workspaces.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd8f2e1..4c608ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [8.1.2] - 2026-05-19 +## [8.2.0] - 2026-06-11 + +### Added +- Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain +- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`) + +### Fixed +- Correct `Policies` `PolicyLimits` to expose `limitCountDailyMessageReceived` and `limitCountDailyEmailSent` (replacing a non-existent per-grant field) +- Correct `Rules` `RuleEvaluation.messageId` to be omitted-when-absent (not nullable) and surface `blockedByEvaluationError` on applied actions +- Correct `Rules` list (`GET /v3/rules`) to normalize its nested `{ data: { items, nextCursor } }` envelope back to the flat `{ data, nextCursor }` shape the list machinery expects +- Correct `Applications` `ApplicationDetails` field `redirectUris` → `callbackUris` (V3 wire contract), widen `region`/`environment` to `string`, add hosted-auth/IdP/source-only fields, and add `applications.update()` (PATCH) +- Correct `Applications` `applications.update()` to accept write-only `additionalSettings` (forwarded in the request body, stripped from the response) +- Correct `RedirectUris` `update()` to use PATCH (was PUT), fix `destroy()` return type, make `platform` optional with a typed `RedirectUriPlatform` enum, and surface `deletedAt` on `RedirectUri` ### Added - Add `attachments.downloadNodeStream()` as a Node.js convenience helper for converting attachment downloads to `NodeJS.ReadableStream` ([#731](https://github.com/nylas/nylas-nodejs/pull/731)) diff --git a/src/models/applicationDetails.ts b/src/models/applicationDetails.ts index 9d6456af..d51fd4f9 100644 --- a/src/models/applicationDetails.ts +++ b/src/models/applicationDetails.ts @@ -8,18 +8,26 @@ export interface ApplicationDetails { * Public Application ID */ applicationId: string; + /** + * V2 Application ID. Omitted when empty. + */ + v2ApplicationId?: string; /** * ID of organization */ organizationId: string; /** - * Region identifier + * Region identifier (e.g. `us`, `eu`). Free-form string, not a closed enum. + */ + region: string; + /** + * Environment identifier (e.g. `sandbox`). Free-form string, not a closed enum. */ - region: 'us' | 'eu'; + environment: string; /** - * Environment identifier + * White-label domain. Omitted when empty. */ - environment: 'production' | 'staging'; + domain?: string; /** * Branding details for the application */ @@ -29,15 +37,34 @@ export interface ApplicationDetails { */ hostedAuthentication?: HostedAuthentication; /** - * List of redirect URIs + * Identity provider (IdP) settings for the application + */ + idpSettings?: IdpSettings; + /** + * List of callback (redirect) URIs. + * + * Read-only on the application object; manage entries via the dedicated + * redirect-uris endpoints (`Nylas.applications.redirectUris`). + */ + callbackUris?: RedirectUri[]; + /** + * Unix timestamp (seconds) when the application was created. Omitted when empty. */ - redirectUris?: RedirectUri[]; + createdAt?: number; + /** + * Unix timestamp (seconds) when the application was last updated. Omitted when empty. + */ + updatedAt?: number; + /** + * Whether the application is blocked. Omitted when empty. + */ + blocked?: boolean; } /** * Interface for branding details for the application */ -interface Branding { +export interface Branding { /** * Name of the application */ @@ -51,7 +78,7 @@ interface Branding { */ websiteUrl?: string; /** - * Description of the appli∏cati∏on + * Description of the application */ description?: string; } @@ -59,7 +86,7 @@ interface Branding { /** * Interface for hosted authentication branding details */ -interface HostedAuthentication { +export interface HostedAuthentication { /** * URL of the background image */ @@ -92,4 +119,100 @@ interface HostedAuthentication { * CSS spacing attribute in px */ spacing?: number; + /** + * URL of the terms of service + */ + termsOfServiceUrl?: string; + /** + * URL of the privacy policy + */ + privacyPolicyUrl?: string; +} + +/** + * Interface for identity provider (IdP) settings for the application + */ +export interface IdpSettings { + /** + * Comma-separated list of allowed origins. Each must be an absolute HTTPS URL + * (HTTP allowed for `localhost`/`127.0.0.1`) with no path, query, fragment, or userinfo. + */ + origins?: string; + /** + * Comma-separated list of allowed issuers. + */ + issuers?: string; +} + +/** + * Interface for additional settings for the application. + * + * Write-only on update: these values can be set via `PATCH /v3/applications` but are + * stripped from every response, so they are not exposed on {@link ApplicationDetails}. + */ +export interface AdditionalSettings { + /** + * Login URL. + */ + loginUrl?: string; + /** + * Logout URL. + */ + logoutUrl?: string; + /** + * Absolute refresh-token expiration, in seconds. + */ + refreshTokenExpirationAbsolute?: number; + /** + * Idle refresh-token expiration, in seconds. + */ + refreshTokenExpirationIdle?: number; + /** + * Whether to rotate the refresh token on use. + */ + rotateRefreshToken?: boolean; + /** + * Whether to allow query parameters in redirect URIs. + */ + allowQueryParamInRedirectUri?: boolean; +} + +/** + * Branding details accepted on the application update path. + * + * Unlike the response {@link Branding} type, `name` is optional here — the source does + * not require `branding.name` on `PATCH /v3/applications`. + */ +export type UpdateBranding = Partial; + +/** + * Interface representing a request to update application details. + * + * All fields are optional; each supplied nested object is a full replace, not a deep merge. + * Note: `callbackUris`/`redirectUris` are ignored by this endpoint — manage callback URIs + * via the dedicated redirect-uris endpoints. + */ +export interface UpdateApplicationRequest { + /** + * Branding details for the application. + */ + branding?: UpdateBranding; + /** + * Hosted authentication branding details. + */ + hostedAuthentication?: HostedAuthentication; + /** + * Identity provider (IdP) settings for the application. + */ + idpSettings?: IdpSettings; + /** + * White-label domain for the application. + */ + domain?: string; + /** + * Additional settings for the application. + * + * Write-only: persisted on update but stripped from the response. + */ + additionalSettings?: AdditionalSettings; } diff --git a/src/models/domains.ts b/src/models/domains.ts new file mode 100644 index 00000000..6bac13d8 --- /dev/null +++ b/src/models/domains.ts @@ -0,0 +1,239 @@ +import { ListQueryParams } from './listQueryParams.js'; + +/** + * Type for the DNS verification types supported by the Manage Domains API. + * + * Note: the published contract only documents the first five, but the + * service source also accepts `dmarc` and `arc`. + */ +export type DomainVerificationType = + | 'ownership' + | 'mx' + | 'spf' + | 'dkim' + | 'feedback' + | 'dmarc' + | 'arc'; + +/** + * Type for the status of a domain DNS verification attempt. + */ +export type DomainVerificationStatus = 'pending' | 'done' | 'failed'; + +/** + * Interface representing a registered email domain. + * + * Every field is optional: the API only emits fields it has populated. + */ +export interface Domain { + /** + * Server-generated domain ID. + */ + id?: string; + /** + * Human-readable label. + */ + name?: string; + /** + * The registered domain (for example, mail.example.com). Stored lowercased. + */ + domainAddress?: string; + /** + * The ID of the organization that owns the domain. Derived from auth, never client-set. + */ + organizationId?: string; + /** + * Whether the domain is a subdomain of a Nylas branded domain. Server-determined at create. + */ + branded?: boolean; + /** + * Cluster region. Server-set from config at create. + */ + region?: string; + /** + * SES tenant key. Set during provisioning. + */ + tenantKey?: string; + /** + * BYODKIM public key. + */ + dkimPublicKey?: string; + /** + * Unix timestamp when the DKIM key was submitted to the provider. + * + * Not returned by Get or List; only populated in the Create response during + * branded provisioning. Treat as optional and do not depend on it from Get/List. + */ + dkimSubmittedAt?: number; + /** + * Ownership (TXT) verification flag. + */ + verifiedOwnership?: boolean; + /** + * MX verification flag. + */ + verifiedMx?: boolean; + /** + * SPF verification flag. + */ + verifiedSpf?: boolean; + /** + * Feedback MX verification flag. + */ + verifiedFeedback?: boolean; + /** + * DKIM verification flag. + */ + verifiedDkim?: boolean; + /** + * DMARC verification flag. + */ + verifiedDmarc?: boolean; + /** + * ARC verification flag. + */ + verifiedArc?: boolean; + /** + * Unix timestamp when the domain was created. + */ + createdAt?: number; + /** + * Unix timestamp when the domain was last updated. + */ + updatedAt?: number; +} + +/** + * Interface representing a request to create a domain. + * + * Other Domain fields are not honored for create: the server sets region, + * branded, the verified flags, id, timestamps, and (for branded) the + * tenant_key/dkim_public_key/dkim_submitted_at. + */ +export interface CreateDomainRequest { + /** + * Human-readable label. + */ + name: string; + /** + * The domain to register. Normalized to lowercase. Cannot duplicate an existing + * domain in the organization. + */ + domainAddress: string; +} + +/** + * Interface representing a request to update a domain. + * + * Only non-null fields are persisted. `domainAddress` cannot be updated and is + * rejected with a 400 if supplied. The update response echoes the sparse + * cleared input (typically just `name` and `updatedAt`), not a full Domain; + * re-fetch the domain if you need the complete record. + */ +export interface UpdateDomainRequest { + /** + * New human-readable label. + */ + name?: string; + /** + * New cluster region. + */ + region?: string; + /** + * New SES tenant key. + */ + tenantKey?: string; + /** + * New BYODKIM public key. + */ + dkimPublicKey?: string; + /** + * Unix timestamp when the DKIM key was submitted to the provider. + */ + dkimSubmittedAt?: number; + /** + * Feedback MX verification flag. This is the only verified flag that can be + * set directly, without running a DNS verification. + */ + verifiedFeedback?: boolean; +} + +/** + * Interface representing a domain DNS verification attempt, used as the request + * body for both the Info and Verify endpoints. + */ +export interface DomainVerificationAttempt { + /** + * The DNS verification type to fetch info for or verify. + */ + type: DomainVerificationType; + /** + * Free-form options. For dkim, may carry a key-length hint. Most callers omit this. + */ + options?: Record; +} + +/** + * Interface representing the DNS record details for a verification attempt + * returned in a verification result. + */ +export interface DomainVerificationAttemptResult { + /** + * The verification type that this attempt corresponds to. + */ + type?: DomainVerificationType; + /** + * The DNS record values to configure (for example, host, type, and value). + */ + options?: Record; +} + +/** + * Interface representing the result of a domain Info or Verify request. + * + * Info and Verify return the same shape. Every field is optional. + */ +export interface DomainVerificationResult { + /** + * The ID of the domain being verified. + */ + domainId?: string; + /** + * The verification attempt, including the DNS record to configure. + */ + attempt?: DomainVerificationAttemptResult; + /** + * The current status for this verification type. + */ + status?: DomainVerificationStatus; + /** + * Unix timestamp when the attempt record was created. + */ + createdAt?: number; + /** + * Unix timestamp when the temporary attempt expires. + */ + expiresAt?: number; + /** + * Optional in-between state. + */ + details?: Record; + /** + * Human-readable instruction for configuring the DNS record. + */ + message?: string; +} + +/** + * Interface representing query parameters for listing domains. + */ +export interface ListDomainsQueryParams extends ListQueryParams { + /** + * Filter by exact domain address. Note the key is `domain`, not `domainAddress`. + */ + domain?: string; + /** + * Filter by region. + */ + region?: string; +} diff --git a/src/models/index.ts b/src/models/index.ts index 140e746c..77327c1b 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -8,6 +8,7 @@ export * from './calendars.js'; export * from './connectors.js'; export * from './contacts.js'; export * from './credentials.js'; +export * from './domains.js'; export * from './drafts.js'; export * from './error.js'; export * from './events.js'; @@ -25,3 +26,4 @@ export * from './scheduler.js'; export * from './smartCompose.js'; export * from './threads.js'; export * from './webhooks.js'; +export * from './workspaces.js'; diff --git a/src/models/policies.ts b/src/models/policies.ts index 42ab2ea5..34addd75 100644 --- a/src/models/policies.ts +++ b/src/models/policies.ts @@ -25,9 +25,13 @@ export interface PolicyLimits { */ limitStorageTotal?: number; /** - * Maximum number of messages each grant can send per day. + * Maximum number of messages each grant can receive per day. Use -1 for unlimited. */ - limitCountDailyMessagePerGrant?: number; + limitCountDailyMessageReceived?: number; + /** + * Maximum number of emails each grant can send per day. Use -1 for unlimited. + */ + limitCountDailyEmailSent?: number; /** * How long, in days, to retain messages in the inbox. */ diff --git a/src/models/redirectUri.ts b/src/models/redirectUri.ts index b6f207f4..922b2ddd 100644 --- a/src/models/redirectUri.ts +++ b/src/models/redirectUri.ts @@ -1,3 +1,10 @@ +/** + * The platform of a Redirect URI. + * + * One of `web`, `js`, `ios`, `android`, or `desktop`. Defaults to `web` when omitted. + */ +export type RedirectUriPlatform = 'web' | 'js' | 'ios' | 'android' | 'desktop'; + /** * Interface representation of a Redirect URI object */ @@ -11,13 +18,17 @@ export type RedirectUri = { */ url: string; /** - * Platform identifier + * Platform identifier. One of `web`, `js`, `ios`, `android`, `desktop`. */ - platform: string; + platform: RedirectUriPlatform; /** * Configuration settings */ settings?: RedirectUriSettings; + /** + * Unix timestamp (seconds) when the redirect URI was soft-deleted. Omitted when empty. + */ + deletedAt?: number; }; /** @@ -59,9 +70,10 @@ export type CreateRedirectUriRequest = { */ url: string; /** - * Platform identifier. + * Platform identifier. One of `web`, `js`, `ios`, `android`, `desktop`. + * Defaults to `web` when omitted. */ - platform: string; + platform?: RedirectUriPlatform; /** * Optional settings for the redirect uri. */ diff --git a/src/models/rules.ts b/src/models/rules.ts index 8f8029a8..9630bdce 100644 --- a/src/models/rules.ts +++ b/src/models/rules.ts @@ -262,6 +262,11 @@ export interface RuleEvaluationAppliedActions { * Whether the inbound message or outbound send was blocked. */ blocked?: boolean; + /** + * Whether the message or send was blocked because an evaluation error + * triggered fail-closed handling, rather than a genuine rule match. + */ + blockedByEvaluationError?: boolean; /** * Whether the message or stored sent copy was moved to spam. */ @@ -302,8 +307,9 @@ export interface RuleEvaluation { grantId: string; /** * The inbound message or stored sent copy associated with this evaluation. + * Omitted (absent) for smtp_rcpt evaluations and outbound sends with no stored copy. */ - messageId?: string | null; + messageId?: string; /** * Unix timestamp when the evaluation occurred. */ diff --git a/src/models/workspaces.ts b/src/models/workspaces.ts new file mode 100644 index 00000000..aecd559e --- /dev/null +++ b/src/models/workspaces.ts @@ -0,0 +1,194 @@ +/** + * Interface representing a Nylas workspace. + * + * A workspace groups grants in a Nylas application by email domain. Grants can be + * auto-grouped (by matching email domain) or manually assigned and removed. + */ +export interface Workspace { + /** + * Globally unique identifier for the workspace. + */ + workspaceId: string; + /** + * The ID of the application that owns the workspace. + * Set server-side from the API key, never from the request body. + */ + applicationId: string; + /** + * Descriptive name for the workspace. + */ + name: string; + /** + * Top-level email domain for the workspace. + * May be an empty string when created with autoGroup false and no domain. + */ + domain: string; + /** + * When true, new grants whose email domain matches domain are auto-assigned. + */ + autoGroup: boolean; + /** + * The ID of the inbox policy attached to the workspace. + */ + policyId?: string; + /** + * The IDs of the inbox rules attached to the workspace. + */ + rulesIds?: string[]; + /** + * Unix timestamp (seconds) when the workspace was created. + */ + createdAt: number; + /** + * Unix timestamp (seconds) when the workspace was last updated. + */ + updatedAt: number; +} + +/** + * Interface representing a request to create a workspace. + */ +export interface CreateWorkspaceRequest { + /** + * Descriptive name for the workspace. + */ + name: string; + /** + * Top-level email domain for the workspace. + * Optional: when omitted along with autoGroup, an empty-domain workspace is created. + */ + domain?: string; + /** + * When true, new grants whose email domain matches domain are auto-assigned. + * Defaults server-side to true when a domain is provided, false otherwise. + */ + autoGroup?: boolean; + /** + * The ID of the inbox policy to attach to the workspace. + */ + policyId?: string; + /** + * The IDs of the inbox rules to attach to the workspace. + */ + rulesIds?: string[]; +} + +/** + * Interface representing a request to update a workspace. + * + * Updates are issued as PATCH and address the workspace by its UUID only. + * At least one field must be provided. + */ +export interface UpdateWorkspaceRequest { + /** + * Descriptive name for the workspace. Omitted fields are preserved. + */ + name?: string; + /** + * Top-level email domain. Changing the domain is rejected by the server; + * the domain is effectively immutable after creation. + */ + domain?: string; + /** + * When true, new grants whose email domain matches domain are auto-assigned. + * Cannot be set to true on a workspace with an empty domain. + */ + autoGroup?: boolean; + /** + * The ID of the inbox policy attached to the workspace. + * A UUID sets the policy, null clears it, and omitting preserves the current value. + */ + policyId?: string | null; + /** + * The IDs of the inbox rules attached to the workspace. + * An array (including an empty array) overwrites; null or omitting preserves. + */ + rulesIds?: string[] | null; +} + +/** + * Interface representing query parameters for listing workspaces. + * + * The list endpoint is not paginated and accepts no query parameters. + */ +export type ListWorkspacesQueryParams = Record; + +/** + * Interface representing a request to auto-group grants into workspaces. + * + * All fields are optional. Auto-grouping runs as a background job. + */ +export interface AutoGroupWorkspacesRequest { + /** + * Only group grants created at or after this Unix timestamp (seconds). + */ + afterCreatedAt?: number; + /** + * When true, includes invalid grants in the grouping pass. Defaults to false. + */ + invalidAlso?: boolean; + /** + * Only group grants whose email domain matches this domain. + */ + specificDomain?: string; +} + +/** + * Interface representing the response from starting an auto-group job. + */ +export interface AutoGroupWorkspacesResponse { + /** + * The ID of the background auto-group job. + */ + jobId: string; + /** + * A human-readable message describing the started job. + */ + message: string; +} + +/** + * Interface representing a request to manually assign or remove grants + * for a workspace. + * + * At least one of assignGrants or removeGrants must contain a grant ID. + * Each list may contain at most 500 entries. + */ +export interface ManualAssignWorkspaceRequest { + /** + * Grant IDs to assign to the workspace. Maximum 500 entries. + */ + assignGrants?: string[]; + /** + * Grant IDs to remove from the workspace. Maximum 500 entries. + */ + removeGrants?: string[]; +} + +/** + * Interface representing the response from manually assigning or removing grants. + */ +export interface ManualAssignWorkspaceResponse { + /** + * The ID of the application that owns the workspace. + */ + applicationId: string; + /** + * The ID of the workspace that was updated. + */ + workspaceId: string; + /** + * The domain of the workspace (empty string if none). + */ + domain: string; + /** + * The grant IDs that were actually assigned. + * Serializes as null (not an empty array) when no assigned grant matched. + */ + grantsAssigned: string[] | null; + /** + * The grant IDs that were actually removed. + * Serializes as null (not an empty array) when no removed grant matched. + */ + grantsRemoved: string[] | null; +} diff --git a/src/nylas.ts b/src/nylas.ts index 98c23773..181d6f53 100644 --- a/src/nylas.ts +++ b/src/nylas.ts @@ -17,8 +17,10 @@ import { Contacts } from './resources/contacts.js'; import { Attachments } from './resources/attachments.js'; import { Scheduler } from './resources/scheduler.js'; import { Notetakers } from './resources/notetakers.js'; +import { Domains } from './resources/domains.js'; import { Policies } from './resources/policies.js'; import { Rules } from './resources/rules.js'; +import { Workspaces } from './resources/workspaces.js'; import { AgentLists } from './resources/agentLists.js'; /** @@ -72,6 +74,10 @@ class Nylas { * Access the Notetakers API */ public notetakers: Notetakers; + /** + * Access the Manage Domains API + */ + public domains: Domains; /** * Access the Agent Account Policies API */ @@ -80,6 +86,10 @@ class Nylas { * Access the Agent Account Rules API */ public rules: Rules; + /** + * Access the Workspaces API + */ + public workspaces: Workspaces; /** * Access the Agent Account Lists API */ @@ -127,8 +137,10 @@ class Nylas { this.grants = new Grants(this.apiClient); this.messages = new Messages(this.apiClient); this.notetakers = new Notetakers(this.apiClient); + this.domains = new Domains(this.apiClient); this.policies = new Policies(this.apiClient); this.rules = new Rules(this.apiClient); + this.workspaces = new Workspaces(this.apiClient); this.lists = new AgentLists(this.apiClient); this.threads = new Threads(this.apiClient); this.webhooks = new Webhooks(this.apiClient); diff --git a/src/resources/applications.ts b/src/resources/applications.ts index c1136f5d..74b074ad 100644 --- a/src/resources/applications.ts +++ b/src/resources/applications.ts @@ -1,11 +1,21 @@ import { Resource } from './resource.js'; import { RedirectUris } from './redirectUris.js'; import APIClient from '../apiClient.js'; -import { ApplicationDetails } from '../models/applicationDetails.js'; +import { + ApplicationDetails, + UpdateApplicationRequest, +} from '../models/applicationDetails.js'; import { NylasResponse } from '../models/response.js'; import { Overrides } from '../config.js'; import { makePathParams } from '../utils.js'; +/** + * @property requestBody The values to update the application with. + */ +export interface UpdateApplicationParams { + requestBody: UpdateApplicationRequest; +} + /** * Nylas Applications API * @@ -37,4 +47,24 @@ export class Applications extends Resource { overrides, }); } + + /** + * Update application details. + * + * Each supplied nested object is a full replace, not a deep merge. Callback URIs cannot + * be updated here — manage them via {@link redirectUris}. + * @returns The updated application details + */ + public update({ + requestBody, + overrides, + }: UpdateApplicationParams & Overrides): Promise< + NylasResponse + > { + return super._updatePatch({ + path: makePathParams('/v3/applications', {}), + requestBody, + overrides, + }); + } } diff --git a/src/resources/domains.ts b/src/resources/domains.ts new file mode 100644 index 00000000..45d1447c --- /dev/null +++ b/src/resources/domains.ts @@ -0,0 +1,200 @@ +import { Overrides } from '../config.js'; +import { + CreateDomainRequest, + Domain, + DomainVerificationAttempt, + DomainVerificationResult, + ListDomainsQueryParams, + UpdateDomainRequest, +} from '../models/domains.js'; +import { + NylasBaseResponse, + NylasListResponse, + NylasResponse, +} from '../models/response.js'; +import { makePathParams } from '../utils.js'; +import { AsyncListResponse, Resource } from './resource.js'; + +/** + * @property queryParams The query parameters to include in the request. + */ +interface ListDomainsParams { + queryParams?: ListDomainsQueryParams; +} + +/** + * @property domainId The ID of the domain to retrieve. Accepts either a domain + * UUID or a domain address (FQDN/email format). + */ +interface FindDomainParams { + domainId: string; +} + +/** + * @property requestBody The values to create the domain with. + */ +interface CreateDomainParams { + requestBody: CreateDomainRequest; +} + +/** + * @property domainId The ID of the domain to update. Accepts either a domain + * UUID or a domain address (FQDN/email format). + * @property requestBody The values to update the domain with. + */ +interface UpdateDomainParams { + domainId: string; + requestBody: UpdateDomainRequest; +} + +/** + * @property domainId The ID of the domain to delete. Accepts either a domain + * UUID or a domain address (FQDN/email format). + */ +interface DestroyDomainParams { + domainId: string; +} + +/** + * @property domainId The ID of the domain to fetch verification info for. + * Accepts either a domain UUID or a domain address (FQDN/email format). + * @property requestBody The verification attempt describing which DNS type to fetch info for. + */ +interface InfoDomainParams { + domainId: string; + requestBody: DomainVerificationAttempt; +} + +/** + * @property domainId The ID of the domain to verify. Accepts either a domain + * UUID or a domain address (FQDN/email format). + * @property requestBody The verification attempt describing which DNS type to verify. + */ +interface VerifyDomainParams { + domainId: string; + requestBody: DomainVerificationAttempt; +} + +/** + * Nylas Manage Domains API + * + * Register email domains and run their DNS verification flow + * (ownership / MX / SPF / DKIM / feedback) for Transactional Send and Nylas Inbound. + */ +export class Domains extends Resource { + /** + * Return all domains for the caller's organization. + * @return The list of domains. + */ + public list({ + queryParams, + overrides, + }: ListDomainsParams & Overrides = {}): AsyncListResponse< + NylasListResponse + > { + return super._list({ + queryParams, + path: makePathParams('/v3/admin/domains', {}), + overrides, + }); + } + + /** + * Return a domain. + * @return The domain. + */ + public find({ + domainId, + overrides, + }: FindDomainParams & Overrides): Promise> { + return super._find({ + path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), + overrides, + }); + } + + /** + * Create a domain. + * @return The created domain. + */ + public create({ + requestBody, + overrides, + }: CreateDomainParams & Overrides): Promise> { + return super._create({ + path: makePathParams('/v3/admin/domains', {}), + requestBody, + overrides, + }); + } + + /** + * Update a domain. + * + * Note: the response echoes the sparse cleared input (typically just `name` + * and `updatedAt`), not a full domain. Re-fetch the domain with `find` if you + * need the complete record. + * @return The updated domain fields. + */ + public update({ + domainId, + requestBody, + overrides, + }: UpdateDomainParams & Overrides): Promise> { + return super._update({ + path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), + requestBody, + overrides, + }); + } + + /** + * Delete a domain. + * @return The deletion response. + */ + public destroy({ + domainId, + overrides, + }: DestroyDomainParams & Overrides): Promise { + return super._destroy({ + path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), + overrides, + }); + } + + /** + * Get the DNS record a customer must configure for a given verification type. + * @return The verification result, including the DNS record to configure. + */ + public info({ + domainId, + requestBody, + overrides, + }: InfoDomainParams & Overrides): Promise< + NylasResponse + > { + return super._create({ + path: makePathParams('/v3/admin/domains/{domainId}/info', { domainId }), + requestBody, + overrides, + }); + } + + /** + * Trigger a DNS verification check for a given verification type. + * @return The verification result, including the current status. + */ + public verify({ + domainId, + requestBody, + overrides, + }: VerifyDomainParams & Overrides): Promise< + NylasResponse + > { + return super._create({ + path: makePathParams('/v3/admin/domains/{domainId}/verify', { domainId }), + requestBody, + overrides, + }); + } +} diff --git a/src/resources/redirectUris.ts b/src/resources/redirectUris.ts index 5cd8f9cb..4ca6d1e0 100644 --- a/src/resources/redirectUris.ts +++ b/src/resources/redirectUris.ts @@ -104,7 +104,7 @@ export class RedirectUris extends Resource { }: UpdateRedirectUrisParams & Overrides): Promise< NylasResponse > { - return super._update({ + return super._updatePatch({ overrides, path: makePathParams('/v3/applications/redirect-uris/{redirectUriId}', { redirectUriId, @@ -115,14 +115,12 @@ export class RedirectUris extends Resource { /** * Delete a Redirect URI - * @return The deleted Redirect URI + * @return The deletion response */ public destroy({ redirectUriId, overrides, - }: DestroyRedirectUrisParams & Overrides): Promise< - NylasResponse - > { + }: DestroyRedirectUrisParams & Overrides): Promise { return super._destroy({ overrides, path: makePathParams('/v3/applications/redirect-uris/{redirectUriId}', { diff --git a/src/resources/resource.ts b/src/resources/resource.ts index 57786095..51fba12d 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -63,6 +63,31 @@ export class Resource { overrides, }); + // Normalize the nested list envelope used by the rules endpoint. + // + // Most list endpoints return a flat envelope: { data: T[], nextCursor }. + // GET /v3/rules instead returns a nested one (after the camelCase transform): + // { data: { items: T[], nextCursor? } } — because the inbox service serializes + // a ListWithCursorResult directly into `data` rather than flattening it (see + // inbox/internal/rule/interface_http_find.go using NewFiberSuccessResponse vs + // policies using NewFiberSuccessListWithCursorResponse). + // + // This guard remaps that nested shape back to the flat shape the list machinery + // (and the public SDK surface) expects. It is a no-op for the normal flat shape: + // it only fires when `data` is a non-null, non-array object carrying an `items` + // array, so it cannot corrupt responses where `data` is already an array. + const data: unknown = (res as { data?: unknown }).data; + if ( + data != null && + !Array.isArray(data) && + typeof data === 'object' && + Array.isArray((data as { items?: unknown }).items) + ) { + const nested = data as { items: unknown[]; nextCursor?: string }; + (res as { data: unknown[] }).data = nested.items; + res.nextCursor = nested.nextCursor ?? res.nextCursor; + } + if (queryParams?.limit) { let entriesRemaining = queryParams.limit; diff --git a/src/resources/workspaces.ts b/src/resources/workspaces.ts new file mode 100644 index 00000000..9ee45131 --- /dev/null +++ b/src/resources/workspaces.ts @@ -0,0 +1,196 @@ +import { Overrides } from '../config.js'; +import { + AutoGroupWorkspacesRequest, + AutoGroupWorkspacesResponse, + CreateWorkspaceRequest, + ListWorkspacesQueryParams, + ManualAssignWorkspaceRequest, + ManualAssignWorkspaceResponse, + UpdateWorkspaceRequest, + Workspace, +} from '../models/workspaces.js'; +import { + NylasBaseResponse, + NylasListResponse, + NylasResponse, +} from '../models/response.js'; +import { makePathParams } from '../utils.js'; +import { AsyncListResponse, Resource } from './resource.js'; + +/** + * @property queryParams The query parameters to include in the request. + */ +interface ListWorkspacesParams { + queryParams?: ListWorkspacesQueryParams; +} + +/** + * @property workspaceId The ID of the workspace to retrieve. Accepts a UUID or a domain. + */ +interface FindWorkspaceParams { + workspaceId: string; +} + +/** + * @property requestBody The values to create the workspace with. + */ +interface CreateWorkspaceParams { + requestBody: CreateWorkspaceRequest; +} + +/** + * @property workspaceId The UUID of the workspace to update. A domain is not accepted here. + * @property requestBody The values to update the workspace with. + */ +interface UpdateWorkspaceParams { + workspaceId: string; + requestBody: UpdateWorkspaceRequest; +} + +/** + * @property workspaceId The ID of the workspace to delete. Accepts a UUID or a domain. + */ +interface DestroyWorkspaceParams { + workspaceId: string; +} + +/** + * @property requestBody The auto-group filters to apply. + */ +interface AutoGroupWorkspacesParams { + requestBody?: AutoGroupWorkspacesRequest; +} + +/** + * @property workspaceId The ID of the workspace to update. Accepts a UUID or a domain. + * @property requestBody The grants to assign and/or remove. + */ +interface ManualAssignWorkspaceParams { + workspaceId: string; + requestBody: ManualAssignWorkspaceRequest; +} + +/** + * Nylas Workspaces API + * + * Workspaces group grants in a Nylas application by email domain. Grants can be + * auto-grouped by matching email domain or manually assigned and removed. + */ +export class Workspaces extends Resource { + /** + * Return all workspaces for the application. + * + * The list endpoint is not paginated and returns every workspace as a flat array. + * @return The list of workspaces. + */ + public list({ + queryParams, + overrides, + }: ListWorkspacesParams & Overrides = {}): AsyncListResponse< + NylasListResponse + > { + return super._list({ + queryParams, + path: makePathParams('/v3/workspaces', {}), + overrides, + }); + } + + /** + * Return a workspace. + * @return The workspace. + */ + public find({ + workspaceId, + overrides, + }: FindWorkspaceParams & Overrides): Promise> { + return super._find({ + path: makePathParams('/v3/workspaces/{workspaceId}', { workspaceId }), + overrides, + }); + } + + /** + * Create a workspace. + * @return The created workspace. + */ + public create({ + requestBody, + overrides, + }: CreateWorkspaceParams & Overrides): Promise> { + return super._create({ + path: makePathParams('/v3/workspaces', {}), + requestBody, + overrides, + }); + } + + /** + * Update a workspace. + * + * Issued as PATCH; the workspace must be addressed by its UUID. + * @return The updated workspace. + */ + public update({ + workspaceId, + requestBody, + overrides, + }: UpdateWorkspaceParams & Overrides): Promise> { + return super._updatePatch({ + path: makePathParams('/v3/workspaces/{workspaceId}', { workspaceId }), + requestBody, + overrides, + }); + } + + /** + * Delete a workspace. + * @return The deletion response. + */ + public destroy({ + workspaceId, + overrides, + }: DestroyWorkspaceParams & Overrides): Promise { + return super._destroy({ + path: makePathParams('/v3/workspaces/{workspaceId}', { workspaceId }), + overrides, + }); + } + + /** + * Start a background job that auto-groups grants into workspaces by email domain. + * @return The auto-group job response. + */ + public autoGroup({ + requestBody, + overrides, + }: AutoGroupWorkspacesParams & Overrides = {}): Promise< + NylasResponse + > { + return super._create({ + path: makePathParams('/v3/workspaces/auto-group', {}), + requestBody: requestBody ?? {}, + overrides, + }); + } + + /** + * Manually assign grants to and/or remove grants from a workspace. + * @return The assignment response. + */ + public manualAssign({ + workspaceId, + requestBody, + overrides, + }: ManualAssignWorkspaceParams & Overrides): Promise< + NylasResponse + > { + return super._create({ + path: makePathParams('/v3/workspaces/{workspaceId}/manual-assign', { + workspaceId, + }), + requestBody, + overrides, + }); + } +} diff --git a/tests/resources/applications.spec.ts b/tests/resources/applications.spec.ts index 5612dfdc..9561c9e1 100644 --- a/tests/resources/applications.spec.ts +++ b/tests/resources/applications.spec.ts @@ -47,6 +47,94 @@ describe('Applications', () => { }); }); + describe('update', () => { + it('should PATCH /v3/applications with the supplied body', async () => { + // Update is a PATCH (not PUT) per the v3 applications spec; each nested + // object is a full replace. + await applications.update({ + requestBody: { + branding: { + name: 'My App', + iconUrl: 'https://example.com/icon.png', + }, + hostedAuthentication: { + alignment: 'center', + termsOfServiceUrl: 'https://example.com/tos', + privacyPolicyUrl: 'https://example.com/privacy', + }, + idpSettings: { + origins: 'https://example.com', + issuers: 'https://issuer.example.com', + }, + domain: 'auth.example.com', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/v3/applications', + body: { + branding: { + name: 'My App', + iconUrl: 'https://example.com/icon.png', + }, + hostedAuthentication: { + alignment: 'center', + termsOfServiceUrl: 'https://example.com/tos', + privacyPolicyUrl: 'https://example.com/privacy', + }, + idpSettings: { + origins: 'https://example.com', + issuers: 'https://issuer.example.com', + }, + domain: 'auth.example.com', + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + + it('should send additionalSettings in the request body when supplied', async () => { + // Per the v3 applications spec (discrepancy #13), additional_settings IS + // updatable via PATCH /v3/applications: the handler parses and persists it, + // and only strips it from the response. The SDK must therefore forward it in + // the request body even though it is never echoed back on the application model. + await applications.update({ + requestBody: { + additionalSettings: { + loginUrl: 'https://example.com/login', + logoutUrl: 'https://example.com/logout', + refreshTokenExpirationAbsolute: 86400, + refreshTokenExpirationIdle: 3600, + rotateRefreshToken: true, + allowQueryParamInRedirectUri: false, + }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/v3/applications', + body: { + additionalSettings: { + loginUrl: 'https://example.com/login', + logoutUrl: 'https://example.com/logout', + refreshTokenExpirationAbsolute: 86400, + refreshTokenExpirationIdle: 3600, + rotateRefreshToken: true, + allowQueryParamInRedirectUri: false, + }, + }, + }); + }); + }); + describe('constructor', () => { it('should initialize the redirect-uri resource', () => { expect(applications.redirectUris.constructor.name).toBe('RedirectUris'); diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts new file mode 100644 index 00000000..25dcf5b6 --- /dev/null +++ b/tests/resources/domains.spec.ts @@ -0,0 +1,254 @@ +import APIClient from '../../src/apiClient'; +import { Domains } from '../../src/resources/domains'; + +jest.mock('../../src/apiClient'); + +describe('Domains', () => { + let apiClient: jest.Mocked; + let domains: Domains; + + beforeEach(() => { + apiClient = new APIClient({ + apiKey: 'apiKey', + apiUri: 'https://test.api.nylas.com', + timeout: 30, + headers: {}, + }) as jest.Mocked; + + domains = new Domains(apiClient); + apiClient.request.mockResolvedValue({ data: [] }); + }); + + describe('list', () => { + it('should call apiClient.request with the correct params', async () => { + await domains.list({ + queryParams: { + limit: 10, + domain: 'mail.example.com', + region: 'us', + }, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + // Path must target the public admin surface, and the address filter key + // is `domain` (not `domainAddress`). + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains', + queryParams: { + limit: 10, + domain: 'mail.example.com', + region: 'us', + }, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('find', () => { + it('should call apiClient.request with the correct params', async () => { + await domains.find({ + domainId: 'domain123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains/domain123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + + it('should accept a domain address as the identifier', async () => { + await domains.find({ + domainId: 'mail.example.com', + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains/mail.example.com', + overrides: undefined, + }); + }); + }); + + describe('create', () => { + it('should call apiClient.request with the correct params', async () => { + const requestBody = { + name: 'Example mail domain', + domainAddress: 'mail.example.com', + }; + + await domains.create({ + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('update', () => { + it('should call apiClient.request with the correct params using PUT', async () => { + const requestBody = { + name: 'Renamed domain', + verifiedFeedback: true, + }; + + await domains.update({ + domainId: 'domain123', + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + // Update is PUT (not PATCH) and targets the admin surface. + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/v3/admin/domains/domain123', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('destroy', () => { + it('should call apiClient.request with the correct params', async () => { + await domains.destroy({ + domainId: 'domain123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/v3/admin/domains/domain123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('info', () => { + it('should POST to the /info subpath with the verification attempt body', async () => { + const requestBody = { + type: 'ownership' as const, + }; + + await domains.info({ + domainId: 'domain123', + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/info', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + + it('should accept options on the verification attempt', async () => { + const requestBody = { + type: 'dkim' as const, + options: { keyLength: 2048 }, + }; + + await domains.info({ + domainId: 'domain123', + requestBody, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/info', + body: requestBody, + overrides: undefined, + }); + }); + }); + + describe('verify', () => { + it('should POST to the /verify subpath with the verification attempt body', async () => { + const requestBody = { + type: 'spf' as const, + }; + + await domains.verify({ + domainId: 'domain123', + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/verify', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + + it('should accept the extended dmarc/arc verification types', async () => { + const requestBody = { + type: 'arc' as const, + }; + + await domains.verify({ + domainId: 'mail.example.com', + requestBody, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/mail.example.com/verify', + body: requestBody, + overrides: undefined, + }); + }); + }); +}); diff --git a/tests/resources/policies.spec.ts b/tests/resources/policies.spec.ts index cc026795..d9a838a4 100644 --- a/tests/resources/policies.spec.ts +++ b/tests/resources/policies.spec.ts @@ -73,6 +73,8 @@ describe('Policies', () => { limits: { limitAttachmentSizeLimit: 26214400, limitAttachmentCountLimit: 50, + limitCountDailyMessageReceived: 1000, + limitCountDailyEmailSent: 500, limitInboxRetentionPeriod: 365, limitSpamRetentionPeriod: 30, }, diff --git a/tests/resources/redirectUris.spec.ts b/tests/resources/redirectUris.spec.ts index 51b68390..ec221359 100644 --- a/tests/resources/redirectUris.spec.ts +++ b/tests/resources/redirectUris.spec.ts @@ -97,7 +97,7 @@ describe('RedirectUris', () => { await redirectUris.create({ requestBody: { url: 'https://test.com', - platform: 'google', + platform: 'ios', settings: { origin: 'https://origin.com', bundleId: 'com.test', @@ -113,12 +113,14 @@ describe('RedirectUris', () => { }, }); + // POST to the redirect-uris collection; the server regenerates the id + // server-side, so no id is sent in the body. expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/applications/redirect-uris', body: { url: 'https://test.com', - platform: 'google', + platform: 'ios', settings: { origin: 'https://origin.com', bundleId: 'com.test', @@ -134,6 +136,22 @@ describe('RedirectUris', () => { }, }); }); + + it('should allow creating without a platform (defaults to web server-side)', async () => { + await redirectUris.create({ + requestBody: { + url: 'https://test.com', + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/applications/redirect-uris', + body: { + url: 'https://test.com', + }, + }); + }); }); describe('update', () => { @@ -142,7 +160,7 @@ describe('RedirectUris', () => { redirectUriId: 'redirect123', requestBody: { url: 'https://test.com', - platform: 'google', + platform: 'ios', settings: { origin: 'https://origin.com', bundleId: 'com.test', @@ -158,12 +176,13 @@ describe('RedirectUris', () => { }, }); + // Update is a PATCH (not PUT) per the v3 applications spec. expect(apiClient.request).toHaveBeenCalledWith({ - method: 'PUT', + method: 'PATCH', path: '/v3/applications/redirect-uris/redirect123', body: { url: 'https://test.com', - platform: 'google', + platform: 'ios', settings: { origin: 'https://origin.com', bundleId: 'com.test', @@ -185,7 +204,7 @@ describe('RedirectUris', () => { redirectUriId: 'redirect/123', requestBody: { url: 'https://test.com', - platform: 'google', + platform: 'web', settings: {}, }, overrides: {}, @@ -202,7 +221,7 @@ describe('RedirectUris', () => { redirectUriId: 'redirect%2F123', requestBody: { url: 'https://test.com', - platform: 'google', + platform: 'web', settings: {}, }, overrides: {}, diff --git a/tests/resources/rules.spec.ts b/tests/resources/rules.spec.ts index 94b2818f..611ee991 100644 --- a/tests/resources/rules.spec.ts +++ b/tests/resources/rules.spec.ts @@ -43,6 +43,65 @@ describe('Rules', () => { }, }); }); + + // GROUND TRUTH (source-verified): GET /v3/rules returns a NESTED list envelope. + // The inbox service serializes a ListWithCursorResult straight into `data`, so + // after the SDK camelCase transform the apiClient yields + // { requestId, data: { items: Rule[], nextCursor? } } — NOT the flat + // { requestId, data: Rule[], nextCursor } that every other list endpoint returns + // (proven by inbox/internal/rule/interface_http_find.go using + // NewFiberSuccessResponse on a ListWithCursorResult, vs policies using + // NewFiberSuccessListWithCursorResponse). The base list machinery must normalize + // this nested shape so the public surface stays consistent: callers still get + // result.data as an array and result.nextCursor at the top level. If the + // normalization is removed, result.data is the {items,nextCursor} OBJECT (so + // toHaveLength / [0].id fail) and result.nextCursor is undefined. + it('should normalize the nested rules list envelope to the flat surface', async () => { + apiClient.request.mockResolvedValue({ + requestId: 'req-1', + data: { + items: [{ id: 'rule123', name: 'Block spam', match: {}, actions: [] }], + nextCursor: 'cursor-abc', + }, + }); + + const result = await rules.list({}); + + expect(Array.isArray(result.data)).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('rule123'); + expect(result.nextCursor).toBe('cursor-abc'); + }); + + // Back-compat: the normalization is a no-op when an endpoint returns the normal + // flat list envelope, so a flat response must still pass through untouched. + it('should leave a flat list envelope untouched (back-compat)', async () => { + apiClient.request.mockResolvedValue({ + requestId: 'req-1', + data: [{ id: 'rule123', name: 'Block spam', match: {}, actions: [] }], + nextCursor: 'cursor-abc', + }); + + const result = await rules.list({}); + + expect(Array.isArray(result.data)).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('rule123'); + expect(result.nextCursor).toBe('cursor-abc'); + }); + + it('should forward pageToken for cursor pagination', async () => { + await rules.list({ + queryParams: { limit: 10, pageToken: 'cursor-abc' }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/rules', + queryParams: { limit: 10, pageToken: 'cursor-abc' }, + overrides: undefined, + }); + }); }); describe('find', () => { @@ -103,6 +162,53 @@ describe('Rules', () => { }, }); }); + + // in_list conditions carry an array of List IDs, and assign_to_folder carries + // a target folder ID in `value`. The body must pass through verbatim so the + // wire receives the array/string shapes the server validates. + it('should pass through in_list array values and assign_to_folder value', async () => { + const requestBody = { + name: 'Route trusted senders', + trigger: 'inbound' as const, + match: { + operator: 'all' as const, + conditions: [ + { + field: 'from.address' as const, + operator: 'in_list' as const, + value: ['list-1', 'list-2'], + }, + ], + }, + actions: [{ type: 'assign_to_folder' as const, value: 'folder-123' }], + }; + + await rules.create({ requestBody }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/rules', + body: requestBody, + overrides: undefined, + }); + }); + + it('should deserialize the flat single-object create response', async () => { + apiClient.request.mockResolvedValue({ + requestId: 'req-2', + data: { id: 'rule123', name: 'Block spam', match: {}, actions: [] }, + }); + + const result = await rules.create({ + requestBody: { + name: 'Block spam', + match: { conditions: [] }, + actions: [], + }, + }); + + expect(result.data.id).toBe('rule123'); + }); }); describe('update', () => { @@ -178,5 +284,29 @@ describe('Rules', () => { }, }); }); + + // rule-evaluations returns a flat array with NO next_cursor (the service never + // computes one). The list must still deserialize cleanly with no cursor, and + // smtp_rcpt records carry no messageId (the key is absent, not null). + it('should deserialize the flat array envelope without a cursor', async () => { + apiClient.request.mockResolvedValue({ + requestId: 'req-3', + data: [ + { + id: 'eval-1', + grantId: 'grant123', + evaluationStage: 'smtp_rcpt', + appliedActions: { blocked: true }, + }, + ], + }); + + const result = await rules.listEvaluations({ identifier: 'grant123' }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].messageId).toBeUndefined(); + expect(result.data[0].appliedActions?.blocked).toBe(true); + expect(result.nextCursor).toBeUndefined(); + }); }); }); diff --git a/tests/resources/workspaces.spec.ts b/tests/resources/workspaces.spec.ts new file mode 100644 index 00000000..5cc52534 --- /dev/null +++ b/tests/resources/workspaces.spec.ts @@ -0,0 +1,210 @@ +import APIClient from '../../src/apiClient'; +import { Workspaces } from '../../src/resources/workspaces'; + +jest.mock('../../src/apiClient'); + +describe('Workspaces', () => { + let apiClient: jest.Mocked; + let workspaces: Workspaces; + + beforeEach(() => { + apiClient = new APIClient({ + apiKey: 'apiKey', + apiUri: 'https://test.api.nylas.com', + timeout: 30, + headers: {}, + }) as jest.Mocked; + + workspaces = new Workspaces(apiClient); + apiClient.request.mockResolvedValue({ data: [] }); + }); + + describe('list', () => { + it('should call apiClient.request with a GET to the collection path', async () => { + await workspaces.list({ + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/workspaces', + queryParams: undefined, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('find', () => { + it('should call apiClient.request with a GET to the workspace path', async () => { + await workspaces.find({ + workspaceId: 'workspace123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/workspaces/workspace123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('create', () => { + it('should call apiClient.request with a POST and the create body', async () => { + const requestBody = { + name: 'Acme Workspace', + domain: 'acme.com', + autoGroup: true, + policyId: 'policy123', + rulesIds: ['rule123', 'rule456'], + }; + + await workspaces.create({ + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/workspaces', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('update', () => { + it('should issue a PATCH (not PUT) to the workspace path with the update body', async () => { + const requestBody = { + name: 'Renamed Workspace', + policyId: null, + rulesIds: ['rule789'], + }; + + await workspaces.update({ + workspaceId: 'workspace123', + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PATCH', + path: '/v3/workspaces/workspace123', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('destroy', () => { + it('should call apiClient.request with a DELETE to the workspace path', async () => { + await workspaces.destroy({ + workspaceId: 'workspace123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/v3/workspaces/workspace123', + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); + + describe('autoGroup', () => { + it('should POST the filters to the auto-group sub-path', async () => { + const requestBody = { + afterCreatedAt: 1700000000, + invalidAlso: true, + specificDomain: 'acme.com', + }; + + await workspaces.autoGroup({ + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/workspaces/auto-group', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + + it('should POST an empty body when no filters are provided', async () => { + await workspaces.autoGroup(); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/workspaces/auto-group', + body: {}, + overrides: undefined, + }); + }); + }); + + describe('manualAssign', () => { + it('should POST assign/remove grants to the manual-assign sub-path', async () => { + const requestBody = { + assignGrants: ['grant123', 'grant456'], + removeGrants: ['grant789'], + }; + + await workspaces.manualAssign({ + workspaceId: 'workspace123', + requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/workspaces/workspace123/manual-assign', + body: requestBody, + overrides: { + apiUri: 'https://override.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + }); + }); +}); From 904d204d34218c9dba7afd86a1d1493ea8137b22 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:30:59 -0400 Subject: [PATCH 02/13] TW-5371 Fix CI formatting --- CHANGELOG.md | 4 +++- tests/resources/rules.spec.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c608ca5..0df1f36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [8.2.0] - 2026-06-11 +## [Unreleased] ### Added - Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correct `Applications` `applications.update()` to accept write-only `additionalSettings` (forwarded in the request body, stripped from the response) - Correct `RedirectUris` `update()` to use PATCH (was PUT), fix `destroy()` return type, make `platform` optional with a typed `RedirectUriPlatform` enum, and surface `deletedAt` on `RedirectUri` +## [8.2.0] - 2026-06-11 + ### Added - Add `attachments.downloadNodeStream()` as a Node.js convenience helper for converting attachment downloads to `NodeJS.ReadableStream` ([#731](https://github.com/nylas/nylas-nodejs/pull/731)) - Support `fields=include_headers` and `fields=include_basic_headers` query param on `messages.send()` ([#732](https://github.com/nylas/nylas-nodejs/pull/732)) diff --git a/tests/resources/rules.spec.ts b/tests/resources/rules.spec.ts index 611ee991..e6540eeb 100644 --- a/tests/resources/rules.spec.ts +++ b/tests/resources/rules.spec.ts @@ -60,7 +60,9 @@ describe('Rules', () => { apiClient.request.mockResolvedValue({ requestId: 'req-1', data: { - items: [{ id: 'rule123', name: 'Block spam', match: {}, actions: [] }], + items: [ + { id: 'rule123', name: 'Block spam', match: {}, actions: [] }, + ], nextCursor: 'cursor-abc', }, }); From 0623c9ecfaba9067b2945750217c842b83989134 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:38:22 -0400 Subject: [PATCH 03/13] TW-5371 Align workspace schema with source --- CHANGELOG.md | 2 +- src/models/workspaces.ts | 10 +++++++--- tests/resources/workspaces.spec.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df1f36d..36d3029f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain +- Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain, `default`, `policyId`, and `ruleIds` - Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`) ### Fixed diff --git a/src/models/workspaces.ts b/src/models/workspaces.ts index aecd559e..95468f20 100644 --- a/src/models/workspaces.ts +++ b/src/models/workspaces.ts @@ -27,6 +27,10 @@ export interface Workspace { * When true, new grants whose email domain matches domain are auto-assigned. */ autoGroup: boolean; + /** + * When true, this is the application's default workspace. + */ + default?: boolean; /** * The ID of the inbox policy attached to the workspace. */ @@ -34,7 +38,7 @@ export interface Workspace { /** * The IDs of the inbox rules attached to the workspace. */ - rulesIds?: string[]; + ruleIds?: string[]; /** * Unix timestamp (seconds) when the workspace was created. */ @@ -70,7 +74,7 @@ export interface CreateWorkspaceRequest { /** * The IDs of the inbox rules to attach to the workspace. */ - rulesIds?: string[]; + ruleIds?: string[]; } /** @@ -103,7 +107,7 @@ export interface UpdateWorkspaceRequest { * The IDs of the inbox rules attached to the workspace. * An array (including an empty array) overwrites; null or omitting preserves. */ - rulesIds?: string[] | null; + ruleIds?: string[] | null; } /** diff --git a/tests/resources/workspaces.spec.ts b/tests/resources/workspaces.spec.ts index 5cc52534..afad6ffe 100644 --- a/tests/resources/workspaces.spec.ts +++ b/tests/resources/workspaces.spec.ts @@ -68,7 +68,7 @@ describe('Workspaces', () => { domain: 'acme.com', autoGroup: true, policyId: 'policy123', - rulesIds: ['rule123', 'rule456'], + ruleIds: ['rule123', 'rule456'], }; await workspaces.create({ @@ -96,7 +96,7 @@ describe('Workspaces', () => { const requestBody = { name: 'Renamed Workspace', policyId: null, - rulesIds: ['rule789'], + ruleIds: ['rule789'], }; await workspaces.update({ From 691dc9cae8ad98d531f27793cde430a91d7c03ac Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:41:19 -0400 Subject: [PATCH 04/13] TW-5371 Cover nested rules cursor fallback --- tests/resources/rules.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/resources/rules.spec.ts b/tests/resources/rules.spec.ts index e6540eeb..8a8cc028 100644 --- a/tests/resources/rules.spec.ts +++ b/tests/resources/rules.spec.ts @@ -75,6 +75,23 @@ describe('Rules', () => { expect(result.nextCursor).toBe('cursor-abc'); }); + it('should preserve top-level cursor when nested rules envelope omits nextCursor', async () => { + apiClient.request.mockResolvedValue({ + requestId: 'req-1', + data: { + items: [ + { id: 'rule123', name: 'Block spam', match: {}, actions: [] }, + ], + }, + nextCursor: 'outer-cursor', + }); + + const result = await rules.list({}); + + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe('outer-cursor'); + }); + // Back-compat: the normalization is a no-op when an endpoint returns the normal // flat list envelope, so a flat response must still pass through untouched. it('should leave a flat list envelope untouched (back-compat)', async () => { From 7b0f8f6ec49a8035ed65a8d28c41b7d3985b6696 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 21:29:01 -0400 Subject: [PATCH 05/13] TW-5371 Hide internal v2 application id --- CHANGELOG.md | 2 +- src/models/applicationDetails.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d3029f..3dccdd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correct `Policies` `PolicyLimits` to expose `limitCountDailyMessageReceived` and `limitCountDailyEmailSent` (replacing a non-existent per-grant field) - Correct `Rules` `RuleEvaluation.messageId` to be omitted-when-absent (not nullable) and surface `blockedByEvaluationError` on applied actions - Correct `Rules` list (`GET /v3/rules`) to normalize its nested `{ data: { items, nextCursor } }` envelope back to the flat `{ data, nextCursor }` shape the list machinery expects -- Correct `Applications` `ApplicationDetails` field `redirectUris` → `callbackUris` (V3 wire contract), widen `region`/`environment` to `string`, add hosted-auth/IdP/source-only fields, and add `applications.update()` (PATCH) +- Correct `Applications` `ApplicationDetails` field `redirectUris` → `callbackUris` (V3 wire contract), widen `region`/`environment` to `string`, add hosted-auth/IdP public fields, and add `applications.update()` (PATCH) - Correct `Applications` `applications.update()` to accept write-only `additionalSettings` (forwarded in the request body, stripped from the response) - Correct `RedirectUris` `update()` to use PATCH (was PUT), fix `destroy()` return type, make `platform` optional with a typed `RedirectUriPlatform` enum, and surface `deletedAt` on `RedirectUri` diff --git a/src/models/applicationDetails.ts b/src/models/applicationDetails.ts index d51fd4f9..bdc9b5fb 100644 --- a/src/models/applicationDetails.ts +++ b/src/models/applicationDetails.ts @@ -8,10 +8,6 @@ export interface ApplicationDetails { * Public Application ID */ applicationId: string; - /** - * V2 Application ID. Omitted when empty. - */ - v2ApplicationId?: string; /** * ID of organization */ From 8afaeb12aa73605b5cf40675b602f3e586a4b371 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:06:51 -0400 Subject: [PATCH 06/13] Add lists admin API support --- CHANGELOG.md | 3 +- src/models/agentLists.ts | 5 + src/models/domains.ts | 48 +--------- src/resources/domains.ts | 61 +++++++++++- src/resources/resource.ts | 15 +-- tests/nylas.spec.ts | 1 + tests/resources/agentLists.spec.ts | 145 ++++++++++++++++++++++++++++- tests/resources/domains.spec.ts | 99 ++++++++------------ tests/resources/rules.spec.ts | 16 ++-- 9 files changed, 263 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dccdd9b..487a120b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain, `default`, `policyId`, and `ruleIds` -- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`) +- Add Agent Account Lists API support via `nylas.lists` — create lists with `name`, optional `description`, and immutable `type`, plus list, find, update, destroy, `listItems()`, `addItems()`, and `removeItems()` for managing `/v3/lists` +- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`). These endpoints require Nylas Service Account request signing headers. ### Fixed - Correct `Policies` `PolicyLimits` to expose `limitCountDailyMessageReceived` and `limitCountDailyEmailSent` (replacing a non-existent per-grant field) diff --git a/src/models/agentLists.ts b/src/models/agentLists.ts index 479723d7..06eb8ec8 100644 --- a/src/models/agentLists.ts +++ b/src/models/agentLists.ts @@ -71,6 +71,9 @@ export interface AgentListItem { /** * Interface representing a request to create a Nylas Agent Account list. + * + * The server derives `id`, `itemsCount`, `applicationId`, `organizationId`, + * `createdAt`, and `updatedAt`; they are intentionally not accepted here. */ export interface CreateAgentListRequest { /** @@ -89,6 +92,8 @@ export interface CreateAgentListRequest { /** * Interface representing a request to update a Nylas Agent Account list. + * + * List `type` is immutable after creation. */ export interface UpdateAgentListRequest { /** diff --git a/src/models/domains.ts b/src/models/domains.ts index 6bac13d8..47d05737 100644 --- a/src/models/domains.ts +++ b/src/models/domains.ts @@ -2,9 +2,6 @@ import { ListQueryParams } from './listQueryParams.js'; /** * Type for the DNS verification types supported by the Manage Domains API. - * - * Note: the published contract only documents the first five, but the - * service source also accepts `dmarc` and `arc`. */ export type DomainVerificationType = | 'ownership' @@ -50,21 +47,6 @@ export interface Domain { * Cluster region. Server-set from config at create. */ region?: string; - /** - * SES tenant key. Set during provisioning. - */ - tenantKey?: string; - /** - * BYODKIM public key. - */ - dkimPublicKey?: string; - /** - * Unix timestamp when the DKIM key was submitted to the provider. - * - * Not returned by Get or List; only populated in the Create response during - * branded provisioning. Treat as optional and do not depend on it from Get/List. - */ - dkimSubmittedAt?: number; /** * Ownership (TXT) verification flag. */ @@ -107,8 +89,7 @@ export interface Domain { * Interface representing a request to create a domain. * * Other Domain fields are not honored for create: the server sets region, - * branded, the verified flags, id, timestamps, and (for branded) the - * tenant_key/dkim_public_key/dkim_submitted_at. + * branded, the verified flags, id, and timestamps. */ export interface CreateDomainRequest { /** @@ -125,37 +106,14 @@ export interface CreateDomainRequest { /** * Interface representing a request to update a domain. * - * Only non-null fields are persisted. `domainAddress` cannot be updated and is - * rejected with a 400 if supplied. The update response echoes the sparse - * cleared input (typically just `name` and `updatedAt`), not a full Domain; - * re-fetch the domain if you need the complete record. + * Only `name` is updatable. `domainAddress` cannot be changed after create; + * delete and recreate the domain to use a different address. */ export interface UpdateDomainRequest { /** * New human-readable label. */ name?: string; - /** - * New cluster region. - */ - region?: string; - /** - * New SES tenant key. - */ - tenantKey?: string; - /** - * New BYODKIM public key. - */ - dkimPublicKey?: string; - /** - * Unix timestamp when the DKIM key was submitted to the provider. - */ - dkimSubmittedAt?: number; - /** - * Feedback MX verification flag. This is the only verified flag that can be - * set directly, without running a DNS verification. - */ - verifiedFeedback?: boolean; } /** diff --git a/src/resources/domains.ts b/src/resources/domains.ts index 45d1447c..37d666b9 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -1,4 +1,4 @@ -import { Overrides } from '../config.js'; +import { Overrides, OverridableNylasConfig } from '../config.js'; import { CreateDomainRequest, Domain, @@ -82,8 +82,42 @@ interface VerifyDomainParams { * (ownership / MX / SPF / DKIM / feedback) for Transactional Send and Nylas Inbound. */ export class Domains extends Resource { + private static readonly REQUIRED_SERVICE_ACCOUNT_HEADERS = [ + 'x-nylas-kid', + 'x-nylas-timestamp', + 'x-nylas-nonce', + 'x-nylas-signature', + ]; + + private assertServiceAccountSigningHeaders( + overrides?: OverridableNylasConfig + ): void { + const headers = { + ...(this.apiClient.headers ?? {}), + ...(overrides?.headers ?? {}), + }; + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([header, value]) => [ + header.toLowerCase(), + value, + ]) + ); + const missingHeader = Domains.REQUIRED_SERVICE_ACCOUNT_HEADERS.some( + (header) => !normalizedHeaders[header]?.trim() + ); + + if (missingHeader) { + throw new Error( + 'Manage Domains API requests require Nylas Service Account signing headers.' + ); + } + } + /** * Return all domains for the caller's organization. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The list of domains. */ public list({ @@ -92,6 +126,7 @@ export class Domains extends Resource { }: ListDomainsParams & Overrides = {}): AsyncListResponse< NylasListResponse > { + this.assertServiceAccountSigningHeaders(overrides); return super._list({ queryParams, path: makePathParams('/v3/admin/domains', {}), @@ -101,12 +136,16 @@ export class Domains extends Resource { /** * Return a domain. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The domain. */ public find({ domainId, overrides, }: FindDomainParams & Overrides): Promise> { + this.assertServiceAccountSigningHeaders(overrides); return super._find({ path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), overrides, @@ -115,12 +154,16 @@ export class Domains extends Resource { /** * Create a domain. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The created domain. */ public create({ requestBody, overrides, }: CreateDomainParams & Overrides): Promise> { + this.assertServiceAccountSigningHeaders(overrides); return super._create({ path: makePathParams('/v3/admin/domains', {}), requestBody, @@ -134,6 +177,9 @@ export class Domains extends Resource { * Note: the response echoes the sparse cleared input (typically just `name` * and `updatedAt`), not a full domain. Re-fetch the domain with `find` if you * need the complete record. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The updated domain fields. */ public update({ @@ -141,6 +187,7 @@ export class Domains extends Resource { requestBody, overrides, }: UpdateDomainParams & Overrides): Promise> { + this.assertServiceAccountSigningHeaders(overrides); return super._update({ path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), requestBody, @@ -150,12 +197,16 @@ export class Domains extends Resource { /** * Delete a domain. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The deletion response. */ public destroy({ domainId, overrides, }: DestroyDomainParams & Overrides): Promise { + this.assertServiceAccountSigningHeaders(overrides); return super._destroy({ path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), overrides, @@ -164,6 +215,9 @@ export class Domains extends Resource { /** * Get the DNS record a customer must configure for a given verification type. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The verification result, including the DNS record to configure. */ public info({ @@ -173,6 +227,7 @@ export class Domains extends Resource { }: InfoDomainParams & Overrides): Promise< NylasResponse > { + this.assertServiceAccountSigningHeaders(overrides); return super._create({ path: makePathParams('/v3/admin/domains/{domainId}/info', { domainId }), requestBody, @@ -182,6 +237,9 @@ export class Domains extends Resource { /** * Trigger a DNS verification check for a given verification type. + * + * Requires Nylas Service Account request signing headers: + * X-Nylas-Kid, X-Nylas-Timestamp, X-Nylas-Nonce, and X-Nylas-Signature. * @return The verification result, including the current status. */ public verify({ @@ -191,6 +249,7 @@ export class Domains extends Resource { }: VerifyDomainParams & Overrides): Promise< NylasResponse > { + this.assertServiceAccountSigningHeaders(overrides); return super._create({ path: makePathParams('/v3/admin/domains/{domainId}/verify', { domainId }), requestBody, diff --git a/src/resources/resource.ts b/src/resources/resource.ts index 51fba12d..9db4da3a 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -63,19 +63,10 @@ export class Resource { overrides, }); - // Normalize the nested list envelope used by the rules endpoint. - // - // Most list endpoints return a flat envelope: { data: T[], nextCursor }. - // GET /v3/rules instead returns a nested one (after the camelCase transform): - // { data: { items: T[], nextCursor? } } — because the inbox service serializes - // a ListWithCursorResult directly into `data` rather than flattening it (see - // inbox/internal/rule/interface_http_find.go using NewFiberSuccessResponse vs - // policies using NewFiberSuccessListWithCursorResponse). - // + // Some list endpoints return a nested envelope after key conversion: + // { data: { items: T[], nextCursor? } }. // This guard remaps that nested shape back to the flat shape the list machinery - // (and the public SDK surface) expects. It is a no-op for the normal flat shape: - // it only fires when `data` is a non-null, non-array object carrying an `items` - // array, so it cannot corrupt responses where `data` is already an array. + // and public SDK surface expect. const data: unknown = (res as { data?: unknown }).data; if ( data != null && diff --git a/tests/nylas.spec.ts b/tests/nylas.spec.ts index 67040485..392a8eac 100644 --- a/tests/nylas.spec.ts +++ b/tests/nylas.spec.ts @@ -22,6 +22,7 @@ describe('Nylas', () => { expect(nylas.webhooks.constructor.name).toBe('Webhooks'); expect(nylas.folders.constructor.name).toBe('Folders'); expect(nylas.attachments.constructor.name).toBe('Attachments'); + expect(nylas.lists.constructor.name).toBe('AgentLists'); }); it('should configure the apiClient', () => { diff --git a/tests/resources/agentLists.spec.ts b/tests/resources/agentLists.spec.ts index 256622da..113326ce 100644 --- a/tests/resources/agentLists.spec.ts +++ b/tests/resources/agentLists.spec.ts @@ -1,4 +1,5 @@ import APIClient from '../../src/apiClient'; +import type { CreateAgentListRequest } from '../../src/models/agentLists'; import { AgentLists } from '../../src/resources/agentLists'; jest.mock('../../src/apiClient'); @@ -7,6 +8,22 @@ describe('AgentLists', () => { let apiClient: jest.Mocked; let lists: AgentLists; + const validCreateRequest = { + name: 'Blocked domains', + description: 'Domains we have identified as sending unwanted mail.', + type: 'domain', + } satisfies CreateAgentListRequest; + + const createRequestWithServerFields = { + name: 'Blocked domains', + type: 'domain', + // @ts-expect-error Create requests exclude server-derived/internal fields. + id: 'list123', + } satisfies CreateAgentListRequest; + + void validCreateRequest; + void createRequestWithServerFields; + beforeEach(() => { apiClient = new APIClient({ apiKey: 'apiKey', @@ -43,6 +60,25 @@ describe('AgentLists', () => { }, }); }); + + it('should forward cursor pagination params', async () => { + await lists.list({ + queryParams: { + limit: 10, + pageToken: 'cursor123', + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/lists', + queryParams: { + limit: 10, + pageToken: 'cursor123', + }, + overrides: undefined, + }); + }); }); describe('find', () => { @@ -64,11 +100,23 @@ describe('AgentLists', () => { }, }); }); + + it('should encode listId path params', async () => { + await lists.find({ + listId: 'list/123', + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/lists/list%2F123', + overrides: undefined, + }); + }); }); describe('create', () => { it('should call apiClient.request with the correct params', async () => { - const requestBody = { + const requestBody: CreateAgentListRequest = { name: 'Blocked domains', description: 'Domains we have identified as sending unwanted mail.', type: 'domain' as const, @@ -92,6 +140,22 @@ describe('AgentLists', () => { }, }); }); + + it('should create a list with only required public fields', async () => { + const requestBody: CreateAgentListRequest = { + name: 'VIP addresses', + type: 'address', + }; + + await lists.create({ requestBody }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/lists', + body: requestBody, + overrides: undefined, + }); + }); }); describe('update', () => { @@ -119,6 +183,24 @@ describe('AgentLists', () => { }, }); }); + + it('should encode listId path params', async () => { + const requestBody = { + name: 'Updated list', + }; + + await lists.update({ + listId: 'list/123', + requestBody, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/v3/lists/list%2F123', + body: requestBody, + overrides: undefined, + }); + }); }); describe('destroy', () => { @@ -140,6 +222,18 @@ describe('AgentLists', () => { }, }); }); + + it('should encode listId path params', async () => { + await lists.destroy({ + listId: 'list/123', + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/v3/lists/list%2F123', + overrides: undefined, + }); + }); }); describe('listItems', () => { @@ -167,6 +261,19 @@ describe('AgentLists', () => { }, }); }); + + it('should encode listId path params', async () => { + await lists.listItems({ + listId: 'list/123', + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/lists/list%2F123/items', + queryParams: undefined, + overrides: undefined, + }); + }); }); describe('addItems', () => { @@ -194,6 +301,24 @@ describe('AgentLists', () => { }, }); }); + + it('should encode listId path params', async () => { + const requestBody = { + items: ['vip@example.com'], + }; + + await lists.addItems({ + listId: 'list/123', + requestBody, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/lists/list%2F123/items', + body: requestBody, + overrides: undefined, + }); + }); }); describe('removeItems', () => { @@ -221,5 +346,23 @@ describe('AgentLists', () => { }, }); }); + + it('should encode listId path params', async () => { + const requestBody = { + items: ['vip@example.com'], + }; + + await lists.removeItems({ + listId: 'list/123', + requestBody, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/v3/lists/list%2F123/items', + body: requestBody, + overrides: undefined, + }); + }); }); }); diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts index 25dcf5b6..13397b52 100644 --- a/tests/resources/domains.spec.ts +++ b/tests/resources/domains.spec.ts @@ -3,6 +3,18 @@ import { Domains } from '../../src/resources/domains'; jest.mock('../../src/apiClient'); +const serviceAccountHeaders = { + 'X-Nylas-Kid': 'service-account-key-id', + 'X-Nylas-Timestamp': '1742932766', + 'X-Nylas-Nonce': 'nonce-1234567890123456', + 'X-Nylas-Signature': 'signed-request', +}; + +const signedOverrides = (headers: Record = {}) => ({ + apiUri: 'https://override.api.nylas.com', + headers: { ...serviceAccountHeaders, ...headers }, +}); + describe('Domains', () => { let apiClient: jest.Mocked; let domains: Domains; @@ -19,6 +31,13 @@ describe('Domains', () => { apiClient.request.mockResolvedValue({ data: [] }); }); + it('should reject ordinary API-key-only requests', () => { + expect(() => domains.find({ domainId: 'domain123' })).toThrow( + 'Manage Domains API requests require Nylas Service Account signing headers.' + ); + expect(apiClient.request).not.toHaveBeenCalled(); + }); + describe('list', () => { it('should call apiClient.request with the correct params', async () => { await domains.list({ @@ -27,10 +46,7 @@ describe('Domains', () => { domain: 'mail.example.com', region: 'us', }, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); // Path must target the public admin surface, and the address filter key @@ -43,10 +59,7 @@ describe('Domains', () => { domain: 'mail.example.com', region: 'us', }, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); }); @@ -55,31 +68,26 @@ describe('Domains', () => { it('should call apiClient.request with the correct params', async () => { await domains.find({ domainId: 'domain123', - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'GET', path: '/v3/admin/domains/domain123', - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); it('should accept a domain address as the identifier', async () => { await domains.find({ domainId: 'mail.example.com', + overrides: signedOverrides(), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'GET', path: '/v3/admin/domains/mail.example.com', - overrides: undefined, + overrides: signedOverrides(), }); }); }); @@ -93,20 +101,14 @@ describe('Domains', () => { await domains.create({ requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains', body: requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); }); @@ -115,16 +117,12 @@ describe('Domains', () => { it('should call apiClient.request with the correct params using PUT', async () => { const requestBody = { name: 'Renamed domain', - verifiedFeedback: true, }; await domains.update({ domainId: 'domain123', requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); // Update is PUT (not PATCH) and targets the admin surface. @@ -132,10 +130,7 @@ describe('Domains', () => { method: 'PUT', path: '/v3/admin/domains/domain123', body: requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); }); @@ -144,19 +139,13 @@ describe('Domains', () => { it('should call apiClient.request with the correct params', async () => { await domains.destroy({ domainId: 'domain123', - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'DELETE', path: '/v3/admin/domains/domain123', - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); }); @@ -170,20 +159,14 @@ describe('Domains', () => { await domains.info({ domainId: 'domain123', requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/domain123/info', body: requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); @@ -196,13 +179,14 @@ describe('Domains', () => { await domains.info({ domainId: 'domain123', requestBody, + overrides: signedOverrides(), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/domain123/info', body: requestBody, - overrides: undefined, + overrides: signedOverrides(), }); }); }); @@ -216,20 +200,14 @@ describe('Domains', () => { await domains.verify({ domainId: 'domain123', requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/domain123/verify', body: requestBody, - overrides: { - apiUri: 'https://override.api.nylas.com', - headers: { override: 'bar' }, - }, + overrides: signedOverrides({ override: 'bar' }), }); }); @@ -241,13 +219,14 @@ describe('Domains', () => { await domains.verify({ domainId: 'mail.example.com', requestBody, + overrides: signedOverrides(), }); expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/mail.example.com/verify', body: requestBody, - overrides: undefined, + overrides: signedOverrides(), }); }); }); diff --git a/tests/resources/rules.spec.ts b/tests/resources/rules.spec.ts index 8a8cc028..072e44f7 100644 --- a/tests/resources/rules.spec.ts +++ b/tests/resources/rules.spec.ts @@ -44,18 +44,14 @@ describe('Rules', () => { }); }); - // GROUND TRUTH (source-verified): GET /v3/rules returns a NESTED list envelope. - // The inbox service serializes a ListWithCursorResult straight into `data`, so - // after the SDK camelCase transform the apiClient yields + // GET /v3/rules can return a nested list envelope, so after the SDK camelCase + // transform the apiClient yields // { requestId, data: { items: Rule[], nextCursor? } } — NOT the flat - // { requestId, data: Rule[], nextCursor } that every other list endpoint returns - // (proven by inbox/internal/rule/interface_http_find.go using - // NewFiberSuccessResponse on a ListWithCursorResult, vs policies using - // NewFiberSuccessListWithCursorResponse). The base list machinery must normalize - // this nested shape so the public surface stays consistent: callers still get + // { requestId, data: Rule[], nextCursor } that the list surface exposes. The + // base list machinery must normalize this nested shape so callers still get // result.data as an array and result.nextCursor at the top level. If the - // normalization is removed, result.data is the {items,nextCursor} OBJECT (so - // toHaveLength / [0].id fail) and result.nextCursor is undefined. + // normalization is removed, result.data is the {items,nextCursor} object and + // result.nextCursor is undefined. it('should normalize the nested rules list envelope to the flat surface', async () => { apiClient.request.mockResolvedValue({ requestId: 'req-1', From dfe27d3c9da8827aacda75bb3ba5600ed8887e10 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:40:05 -0400 Subject: [PATCH 07/13] Add service account signing for domains --- CHANGELOG.md | 2 +- src/apiClient.ts | 15 +- src/config.ts | 6 + src/models/index.ts | 1 + src/models/serviceAccount.ts | 160 ++++++++++++++++ src/resources/domains.ts | 167 +++++++++++++--- src/resources/resource.ts | 16 +- tests/apiClient.spec.ts | 69 +++++++ tests/models/serviceAccount.spec.ts | 87 +++++++++ tests/resources/domains.spec.ts | 284 +++++++++++++++++++++++++++- 10 files changed, 768 insertions(+), 39 deletions(-) create mode 100644 src/models/serviceAccount.ts create mode 100644 tests/models/serviceAccount.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 487a120b..dbd4f93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain, `default`, `policyId`, and `ruleIds` - Add Agent Account Lists API support via `nylas.lists` — create lists with `name`, optional `description`, and immutable `type`, plus list, find, update, destroy, `listItems()`, `addItems()`, and `removeItems()` for managing `/v3/lists` -- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`). These endpoints require Nylas Service Account request signing headers. +- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`). Includes `ServiceAccountSigner` support for Nylas Service Account request signing. ### Fixed - Correct `Policies` `PolicyLimits` to expose `limitCountDailyMessageReceived` and `limitCountDailyEmailSent` (replacing a non-existent per-grant field) diff --git a/src/apiClient.ts b/src/apiClient.ts index 5120528e..77ac79e3 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -36,6 +36,7 @@ export interface RequestOptionsParams { headers?: Record; queryParams?: Record; body?: any; + serializedBody?: string | Buffer; form?: FormData; overrides?: OverridableNylasConfig; } @@ -146,12 +147,17 @@ export default class APIClient { ...overrides?.headers, }; - return { + const defaultHeaders: Record = { Accept: 'application/json', 'User-Agent': `Nylas Node SDK v${SDK_VERSION}`, - Authorization: `Bearer ${overrides?.apiKey || this.apiKey}`, ...mergedHeaders, }; + + if (!overrides?.skipAuth) { + defaultHeaders.Authorization = `Bearer ${overrides?.apiKey || this.apiKey}`; + } + + return defaultHeaders; } private async sendRequest(options: RequestOptionsParams): Promise { @@ -257,7 +263,10 @@ export default class APIClient { requestOptions.headers = this.setRequestHeaders(optionParams); requestOptions.method = optionParams.method; - if (optionParams.body) { + if (optionParams.serializedBody) { + requestOptions.body = optionParams.serializedBody; + requestOptions.headers['Content-Type'] = 'application/json'; + } else if (optionParams.body) { requestOptions.body = JSON.stringify( objKeysToSnakeCase(optionParams.body, ['metadata']) // metadata should remain as is ); diff --git a/src/config.ts b/src/config.ts index a9c183b6..55707da5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,12 @@ export type NylasConfig = { export type OverridableNylasConfig = { apiKey?: string; apiUri?: string; + /** + * Suppress the default bearer Authorization header for endpoints that use a + * different authentication mechanism. + * @ignore Not for public use + */ + skipAuth?: boolean; /** * @deprecated Providing timeout in milliseconds is deprecated and will be removed in the next major release. Please use seconds instead. */ diff --git a/src/models/index.ts b/src/models/index.ts index 77327c1b..5803a387 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -23,6 +23,7 @@ export * from './redirectUri.js'; export * from './response.js'; export * from './rules.js'; export * from './scheduler.js'; +export * from './serviceAccount.js'; export * from './smartCompose.js'; export * from './threads.js'; export * from './webhooks.js'; diff --git a/src/models/serviceAccount.ts b/src/models/serviceAccount.ts new file mode 100644 index 00000000..afa6ef16 --- /dev/null +++ b/src/models/serviceAccount.ts @@ -0,0 +1,160 @@ +import { + createPrivateKey, + createSign, + KeyObject, + randomInt, +} from 'node:crypto'; + +const NONCE_LENGTH = 20; +const NONCE_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +const SIGNED_BODY_METHODS = new Set(['post', 'put', 'patch']); + +/** + * Configuration for Nylas Service Account request signing. + */ +export interface ServiceAccountSignerConfig { + /** + * RSA private key in PEM format. Base64-encoded PEM is also accepted. + */ + privateKeyPem: string | Buffer; + /** + * The `private_key_id` from the Nylas Service Account credentials file. + */ + privateKeyId: string; +} + +/** + * Inputs for creating Nylas Service Account signing headers. + */ +export interface BuildServiceAccountHeadersParams { + method: string; + path: string; + body?: Record; + timestamp?: number; + nonce?: string; +} + +/** + * Nylas Service Account signing headers and the canonical JSON body, when the + * request method signs a payload. + */ +export interface ServiceAccountSignedRequest { + headers: Record; + serializedBody?: string; +} + +function normalizePrivateKey(privateKeyPem: string | Buffer): string | Buffer { + if (Buffer.isBuffer(privateKeyPem) || privateKeyPem.includes('BEGIN')) { + return privateKeyPem; + } + + return Buffer.from(privateKeyPem, 'base64').toString('utf8'); +} + +function loadRsaPrivateKey(privateKeyPem: string | Buffer): KeyObject { + const privateKey = createPrivateKey(normalizePrivateKey(privateKeyPem)); + if (privateKey.asymmetricKeyType !== 'rsa') { + throw new Error('Service account private key must be RSA.'); + } + return privateKey; +} + +export function generateNonce(length = NONCE_LENGTH): string { + if (!Number.isInteger(length) || length <= 0) { + throw new Error('Nonce length must be a positive integer.'); + } + + let nonce = ''; + for (let i = 0; i < length; i++) { + nonce += NONCE_ALPHABET[randomInt(NONCE_ALPHABET.length)]; + } + return nonce; +} + +function canonicalValue(value: unknown): string { + if (value === undefined || typeof value === 'function') { + return 'null'; + } + + if (Array.isArray(value)) { + return `[${value.map(canonicalValue).join(',')}]`; + } + + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Service account signing only supports finite numbers.'); + } + + if (value && typeof value === 'object') { + const data = value as Record; + return `{${Object.keys(data) + .filter((key) => { + const item = data[key]; + return item !== undefined && typeof item !== 'function'; + }) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalValue(data[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +/** + * Serialize JSON deterministically with sorted object keys and no extra + * whitespace, matching Nylas Service Account signing requirements. + */ +export function canonicalJson(data: Record): string { + return canonicalValue(data); +} + +/** + * Generates Nylas Service Account request signing headers. + */ +export class ServiceAccountSigner { + private readonly privateKey: KeyObject; + private readonly privateKeyId: string; + + constructor({ privateKeyPem, privateKeyId }: ServiceAccountSignerConfig) { + this.privateKey = loadRsaPrivateKey(privateKeyPem); + this.privateKeyId = privateKeyId; + } + + public buildHeaders({ + method, + path, + body, + timestamp = Math.floor(Date.now() / 1000), + nonce = generateNonce(), + }: BuildServiceAccountHeadersParams): ServiceAccountSignedRequest { + const methodLower = method.toLowerCase(); + const serializedBody = + body && SIGNED_BODY_METHODS.has(methodLower) + ? canonicalJson(body) + : undefined; + const signingEnvelope: Record = { + method: methodLower, + nonce, + path, + timestamp, + }; + + if (serializedBody) { + signingEnvelope.payload = serializedBody; + } + + const signature = createSign('RSA-SHA256') + .update(canonicalJson(signingEnvelope)) + .sign(this.privateKey, 'base64'); + + return { + headers: { + 'X-Nylas-Kid': this.privateKeyId, + 'X-Nylas-Nonce': nonce, + 'X-Nylas-Timestamp': String(timestamp), + 'X-Nylas-Signature': signature, + }, + serializedBody, + }; + } +} diff --git a/src/resources/domains.ts b/src/resources/domains.ts index 37d666b9..6bc3ccc7 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -12,7 +12,8 @@ import { NylasListResponse, NylasResponse, } from '../models/response.js'; -import { makePathParams } from '../utils.js'; +import { ServiceAccountSigner } from '../models/serviceAccount.js'; +import { makePathParams, objKeysToSnakeCase } from '../utils.js'; import { AsyncListResponse, Resource } from './resource.js'; /** @@ -20,6 +21,7 @@ import { AsyncListResponse, Resource } from './resource.js'; */ interface ListDomainsParams { queryParams?: ListDomainsQueryParams; + signer?: ServiceAccountSigner; } /** @@ -28,6 +30,7 @@ interface ListDomainsParams { */ interface FindDomainParams { domainId: string; + signer?: ServiceAccountSigner; } /** @@ -35,6 +38,7 @@ interface FindDomainParams { */ interface CreateDomainParams { requestBody: CreateDomainRequest; + signer?: ServiceAccountSigner; } /** @@ -45,6 +49,7 @@ interface CreateDomainParams { interface UpdateDomainParams { domainId: string; requestBody: UpdateDomainRequest; + signer?: ServiceAccountSigner; } /** @@ -53,6 +58,7 @@ interface UpdateDomainParams { */ interface DestroyDomainParams { domainId: string; + signer?: ServiceAccountSigner; } /** @@ -63,6 +69,7 @@ interface DestroyDomainParams { interface InfoDomainParams { domainId: string; requestBody: DomainVerificationAttempt; + signer?: ServiceAccountSigner; } /** @@ -73,6 +80,15 @@ interface InfoDomainParams { interface VerifyDomainParams { domainId: string; requestBody: DomainVerificationAttempt; + signer?: ServiceAccountSigner; +} + +interface SignedRequestParams { + method: string; + path: string; + requestBody?: Record; + signer?: ServiceAccountSigner; + overrides?: OverridableNylasConfig; } /** @@ -113,6 +129,39 @@ export class Domains extends Resource { } } + private buildSignedRequest({ + method, + path, + requestBody, + signer, + overrides, + }: SignedRequestParams): { + overrides?: OverridableNylasConfig; + serializedBody?: string; + } { + if (!signer) { + this.assertServiceAccountSigningHeaders(overrides); + return { overrides: { ...overrides, skipAuth: true } }; + } + + const body = requestBody ? objKeysToSnakeCase(requestBody) : undefined; + const signedRequest = signer.buildHeaders({ method, path, body }); + const signedOverrides = { + ...overrides, + skipAuth: true, + headers: { + ...(overrides?.headers ?? {}), + ...signedRequest.headers, + }, + }; + + this.assertServiceAccountSigningHeaders(signedOverrides); + return { + overrides: signedOverrides, + serializedBody: signedRequest.serializedBody, + }; + } + /** * Return all domains for the caller's organization. * @@ -122,15 +171,35 @@ export class Domains extends Resource { */ public list({ queryParams, + signer, overrides, }: ListDomainsParams & Overrides = {}): AsyncListResponse< NylasListResponse > { - this.assertServiceAccountSigningHeaders(overrides); + const path = makePathParams('/v3/admin/domains', {}); + if (signer) { + return super._list({ + queryParams, + path, + getOverrides: () => + this.buildSignedRequest({ + method: 'GET', + path, + signer, + overrides, + }).overrides, + }); + } + + const signed = this.buildSignedRequest({ + method: 'GET', + path, + overrides, + }); return super._list({ queryParams, - path: makePathParams('/v3/admin/domains', {}), - overrides, + path, + overrides: signed.overrides, }); } @@ -143,13 +212,20 @@ export class Domains extends Resource { */ public find({ domainId, + signer, overrides, }: FindDomainParams & Overrides): Promise> { - this.assertServiceAccountSigningHeaders(overrides); - return super._find({ - path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), + const path = makePathParams('/v3/admin/domains/{domainId}', { domainId }); + const signed = this.buildSignedRequest({ + method: 'GET', + path, + signer, overrides, }); + return super._find({ + path, + overrides: signed.overrides, + }); } /** @@ -161,14 +237,23 @@ export class Domains extends Resource { */ public create({ requestBody, + signer, overrides, }: CreateDomainParams & Overrides): Promise> { - this.assertServiceAccountSigningHeaders(overrides); - return super._create({ - path: makePathParams('/v3/admin/domains', {}), + const path = makePathParams('/v3/admin/domains', {}); + const signed = this.buildSignedRequest({ + method: 'POST', + path, requestBody, + signer, overrides, }); + return super._create({ + path, + requestBody, + serializedBody: signed.serializedBody, + overrides: signed.overrides, + }); } /** @@ -185,14 +270,23 @@ export class Domains extends Resource { public update({ domainId, requestBody, + signer, overrides, }: UpdateDomainParams & Overrides): Promise> { - this.assertServiceAccountSigningHeaders(overrides); - return super._update({ - path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), + const path = makePathParams('/v3/admin/domains/{domainId}', { domainId }); + const signed = this.buildSignedRequest({ + method: 'PUT', + path, requestBody, + signer, overrides, }); + return super._update({ + path, + requestBody, + serializedBody: signed.serializedBody, + overrides: signed.overrides, + }); } /** @@ -204,13 +298,20 @@ export class Domains extends Resource { */ public destroy({ domainId, + signer, overrides, }: DestroyDomainParams & Overrides): Promise { - this.assertServiceAccountSigningHeaders(overrides); - return super._destroy({ - path: makePathParams('/v3/admin/domains/{domainId}', { domainId }), + const path = makePathParams('/v3/admin/domains/{domainId}', { domainId }); + const signed = this.buildSignedRequest({ + method: 'DELETE', + path, + signer, overrides, }); + return super._destroy({ + path, + overrides: signed.overrides, + }); } /** @@ -223,16 +324,27 @@ export class Domains extends Resource { public info({ domainId, requestBody, + signer, overrides, }: InfoDomainParams & Overrides): Promise< NylasResponse > { - this.assertServiceAccountSigningHeaders(overrides); - return super._create({ - path: makePathParams('/v3/admin/domains/{domainId}/info', { domainId }), + const path = makePathParams('/v3/admin/domains/{domainId}/info', { + domainId, + }); + const signed = this.buildSignedRequest({ + method: 'POST', + path, requestBody, + signer, overrides, }); + return super._create({ + path, + requestBody, + serializedBody: signed.serializedBody, + overrides: signed.overrides, + }); } /** @@ -245,15 +357,26 @@ export class Domains extends Resource { public verify({ domainId, requestBody, + signer, overrides, }: VerifyDomainParams & Overrides): Promise< NylasResponse > { - this.assertServiceAccountSigningHeaders(overrides); - return super._create({ - path: makePathParams('/v3/admin/domains/{domainId}/verify', { domainId }), + const path = makePathParams('/v3/admin/domains/{domainId}/verify', { + domainId, + }); + const signed = this.buildSignedRequest({ + method: 'POST', + path, requestBody, + signer, overrides, }); + return super._create({ + path, + requestBody, + serializedBody: signed.serializedBody, + overrides: signed.overrides, + }); } } diff --git a/src/resources/resource.ts b/src/resources/resource.ts index 9db4da3a..e619bc71 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -11,6 +11,7 @@ interface ListParams { queryParams?: ListQueryParams; path: string; overrides?: OverridableNylasConfig; + getOverrides?: () => OverridableNylasConfig | undefined; useGenerator?: boolean; } @@ -24,6 +25,7 @@ interface PayloadParams { path: string; queryParams?: Record; requestBody: Record; + serializedBody?: string | Buffer; overrides?: OverridableNylasConfig; } @@ -55,12 +57,13 @@ export class Resource { queryParams, path, overrides, + getOverrides, }: ListParams): Promise { const res = await this.apiClient.request({ method: 'GET', path, queryParams, - overrides, + overrides: getOverrides ? getOverrides() : overrides, }); // Some list endpoints return a nested envelope after key conversion: @@ -97,7 +100,7 @@ export class Resource { limit: entriesRemaining, pageToken: res.nextCursor, }, - overrides, + overrides: getOverrides ? getOverrides() : overrides, }); res.data = res.data.concat(nextRes.data); @@ -172,13 +175,20 @@ export class Resource { private payloadRequest( method: 'POST' | 'PUT' | 'PATCH', - { path, queryParams, requestBody, overrides }: PayloadParams + { + path, + queryParams, + requestBody, + serializedBody, + overrides, + }: PayloadParams ): Promise> { return this.apiClient.request>({ method, path, queryParams, body: requestBody, + serializedBody, overrides, }); } diff --git a/tests/apiClient.spec.ts b/tests/apiClient.spec.ts index 032a1ba8..7873b825 100644 --- a/tests/apiClient.spec.ts +++ b/tests/apiClient.spec.ts @@ -204,6 +204,75 @@ describe('APIClient', () => { ); expect(newRequest.body?.toString()).toBe('{"id":"abc123"}'); }); + + it('should preserve service account signing headers from overrides', async () => { + const newRequest = await client.newRequest({ + path: '/v3/admin/domains', + method: 'GET', + overrides: { + headers: { + 'X-Nylas-Kid': 'service-account-key-id', + 'X-Nylas-Timestamp': '1742932766', + 'X-Nylas-Nonce': 'nonce-1234567890123456', + 'X-Nylas-Signature': 'signed-request', + }, + }, + }); + + expect(newRequest.headers.get('Authorization')).toEqual( + 'Bearer testApiKey' + ); + expect(newRequest.headers.get('X-Nylas-Kid')).toEqual( + 'service-account-key-id' + ); + expect(newRequest.headers.get('X-Nylas-Timestamp')).toEqual( + '1742932766' + ); + expect(newRequest.headers.get('X-Nylas-Nonce')).toEqual( + 'nonce-1234567890123456' + ); + expect(newRequest.headers.get('X-Nylas-Signature')).toEqual( + 'signed-request' + ); + }); + + it('should omit the authorization header when skipAuth is set', async () => { + const newRequest = await client.newRequest({ + path: '/v3/admin/domains', + method: 'GET', + overrides: { + skipAuth: true, + headers: { + 'X-Nylas-Kid': 'service-account-key-id', + }, + }, + }); + + expect(newRequest.headers.get('Authorization')).toBeNull(); + expect(newRequest.headers.get('X-Nylas-Kid')).toEqual( + 'service-account-key-id' + ); + }); + + it('should send a pre-serialized JSON body when provided', async () => { + const newRequest = await client.newRequest({ + path: '/v3/admin/domains', + method: 'POST', + body: { + name: 'Example mail domain', + domainAddress: 'mail.example.com', + }, + serializedBody: + '{"domain_address":"mail.example.com","name":"Example mail domain"}', + }); + + expect(await newRequest.text()).toEqual( + '{"domain_address":"mail.example.com","name":"Example mail domain"}' + ); + expect(newRequest.headers.get('Content-Type')).toEqual( + 'application/json' + ); + }); }); describe('requestWithResponse', () => { diff --git a/tests/models/serviceAccount.spec.ts b/tests/models/serviceAccount.spec.ts new file mode 100644 index 00000000..a54fb887 --- /dev/null +++ b/tests/models/serviceAccount.spec.ts @@ -0,0 +1,87 @@ +import { createVerify, generateKeyPairSync } from 'node:crypto'; +import { + canonicalJson, + ServiceAccountSigner, +} from '../../src/models/serviceAccount'; + +describe('ServiceAccountSigner', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const privateKeyPem = privateKey.export({ + type: 'pkcs1', + format: 'pem', + }) as string; + + it('should serialize JSON canonically with sorted keys', () => { + expect( + canonicalJson({ + z: 1, + a: { y: true, x: 'value' }, + list: [{ b: 2, a: 1 }], + }) + ).toEqual('{"a":{"x":"value","y":true},"list":[{"a":1,"b":2}],"z":1}'); + }); + + it('should create service account signing headers', () => { + const signer = new ServiceAccountSigner({ + privateKeyPem, + privateKeyId: 'service-account-key-id', + }); + + const signed = signer.buildHeaders({ + method: 'POST', + path: '/v3/admin/domains', + body: { + name: 'My transactional domain', + domain_address: 'mail.example.com', + }, + timestamp: 1742932766, + nonce: 'nonce-1234567890123456', + }); + + expect(signed.headers).toEqual({ + 'X-Nylas-Kid': 'service-account-key-id', + 'X-Nylas-Nonce': 'nonce-1234567890123456', + 'X-Nylas-Timestamp': '1742932766', + 'X-Nylas-Signature': expect.any(String), + }); + expect(signed.serializedBody).toEqual( + '{"domain_address":"mail.example.com","name":"My transactional domain"}' + ); + + const canonicalEnvelope = canonicalJson({ + method: 'post', + nonce: 'nonce-1234567890123456', + path: '/v3/admin/domains', + payload: + '{"domain_address":"mail.example.com","name":"My transactional domain"}', + timestamp: 1742932766, + }); + const verifier = createVerify('RSA-SHA256'); + verifier.update(canonicalEnvelope); + + expect( + verifier.verify(publicKey, signed.headers['X-Nylas-Signature'], 'base64') + ).toBe(true); + }); + + it('should produce deterministic signatures with fixed timestamp and nonce', () => { + const signer = new ServiceAccountSigner({ + privateKeyPem, + privateKeyId: 'service-account-key-id', + }); + const request = { + method: 'GET', + path: '/v3/admin/domains/domain123', + timestamp: 1742932766, + nonce: 'nonce-1234567890123456', + }; + + const first = signer.buildHeaders(request); + const second = signer.buildHeaders(request); + + expect(second).toEqual(first); + expect(first.serializedBody).toBeUndefined(); + }); +}); diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts index 13397b52..29d5fc57 100644 --- a/tests/resources/domains.spec.ts +++ b/tests/resources/domains.spec.ts @@ -1,4 +1,5 @@ import APIClient from '../../src/apiClient'; +import { ServiceAccountSigner } from '../../src/models/serviceAccount'; import { Domains } from '../../src/resources/domains'; jest.mock('../../src/apiClient'); @@ -15,6 +16,22 @@ const signedOverrides = (headers: Record = {}) => ({ headers: { ...serviceAccountHeaders, ...headers }, }); +const expectedSignedOverrides = (headers: Record = {}) => ({ + ...signedOverrides(headers), + skipAuth: true, +}); + +const signingHeaders = { + 'X-Nylas-Kid': 'signed-kid', + 'X-Nylas-Timestamp': '1742932766', + 'X-Nylas-Nonce': 'nonce-1234567890123456', + 'X-Nylas-Signature': 'generated-signature', +}; + +const signer = { + buildHeaders: jest.fn(), +} as unknown as jest.Mocked; + describe('Domains', () => { let apiClient: jest.Mocked; let domains: Domains; @@ -29,6 +46,8 @@ describe('Domains', () => { domains = new Domains(apiClient); apiClient.request.mockResolvedValue({ data: [] }); + signer.buildHeaders.mockReset(); + signer.buildHeaders.mockReturnValue({ headers: signingHeaders }); }); it('should reject ordinary API-key-only requests', () => { @@ -38,6 +57,123 @@ describe('Domains', () => { expect(apiClient.request).not.toHaveBeenCalled(); }); + it('should reject requests when a required signing header is blank', () => { + expect(() => + domains.find({ + domainId: 'domain123', + overrides: signedOverrides({ 'X-Nylas-Signature': ' ' }), + }) + ).toThrow( + 'Manage Domains API requests require Nylas Service Account signing headers.' + ); + expect(apiClient.request).not.toHaveBeenCalled(); + }); + + it('should accept signing headers case-insensitively', async () => { + await domains.find({ + domainId: 'domain123', + overrides: { + skipAuth: true, + headers: { + 'x-nylas-kid': 'service-account-key-id', + 'x-nylas-timestamp': '1742932766', + 'x-nylas-nonce': 'nonce-1234567890123456', + 'x-nylas-signature': 'signed-request', + }, + }, + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains/domain123', + queryParams: undefined, + overrides: { + skipAuth: true, + headers: { + 'x-nylas-kid': 'service-account-key-id', + 'x-nylas-timestamp': '1742932766', + 'x-nylas-nonce': 'nonce-1234567890123456', + 'x-nylas-signature': 'signed-request', + }, + }, + }); + }); + + it('should sign list requests with a service account signer', async () => { + await domains.list({ signer }); + + expect(signer.buildHeaders).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains', + body: undefined, + }); + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains', + queryParams: undefined, + overrides: { + skipAuth: true, + headers: signingHeaders, + }, + }); + }); + + it('should generate fresh signer headers for paginated list requests', async () => { + const firstHeaders = { + ...signingHeaders, + 'X-Nylas-Nonce': 'first-nonce', + 'X-Nylas-Signature': 'first-signature', + }; + const secondHeaders = { + ...signingHeaders, + 'X-Nylas-Nonce': 'second-nonce', + 'X-Nylas-Signature': 'second-signature', + }; + signer.buildHeaders + .mockReturnValueOnce({ headers: firstHeaders }) + .mockReturnValueOnce({ headers: secondHeaders }); + apiClient.request + .mockResolvedValueOnce({ + data: [{ id: 'domain-1' }], + nextCursor: 'cursor-2', + }) + .mockResolvedValueOnce({ + data: [], + }); + + await domains.list({ + queryParams: { + limit: 2, + }, + signer, + }); + + expect(signer.buildHeaders).toHaveBeenCalledTimes(2); + expect(apiClient.request).toHaveBeenNthCalledWith(1, { + method: 'GET', + path: '/v3/admin/domains', + queryParams: { + limit: 2, + }, + overrides: { + skipAuth: true, + headers: firstHeaders, + }, + }); + expect(apiClient.request).toHaveBeenNthCalledWith(2, { + method: 'GET', + path: '/v3/admin/domains', + queryParams: { + limit: 1, + pageToken: 'cursor-2', + }, + overrides: { + skipAuth: true, + headers: secondHeaders, + }, + }); + }); + describe('list', () => { it('should call apiClient.request with the correct params', async () => { await domains.list({ @@ -59,7 +195,7 @@ describe('Domains', () => { domain: 'mail.example.com', region: 'us', }, - overrides: signedOverrides({ override: 'bar' }), + overrides: expectedSignedOverrides({ override: 'bar' }), }); }); }); @@ -74,7 +210,8 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'GET', path: '/v3/admin/domains/domain123', - overrides: signedOverrides({ override: 'bar' }), + queryParams: undefined, + overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -87,7 +224,8 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'GET', path: '/v3/admin/domains/mail.example.com', - overrides: signedOverrides(), + queryParams: undefined, + overrides: expectedSignedOverrides(), }); }); }); @@ -107,8 +245,52 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains', + queryParams: undefined, body: requestBody, - overrides: signedOverrides({ override: 'bar' }), + serializedBody: undefined, + overrides: expectedSignedOverrides({ override: 'bar' }), + }); + }); + + it('should sign create requests and send the canonical body', async () => { + const requestBody = { + name: 'Example mail domain', + domainAddress: 'mail.example.com', + }; + signer.buildHeaders.mockReturnValue({ + headers: signingHeaders, + serializedBody: + '{"domain_address":"mail.example.com","name":"Example mail domain"}', + }); + + await domains.create({ + requestBody, + signer, + overrides: { headers: { 'X-Custom': 'value' } }, + }); + + expect(signer.buildHeaders).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains', + body: { + name: 'Example mail domain', + domain_address: 'mail.example.com', + }, + }); + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains', + queryParams: undefined, + body: requestBody, + serializedBody: + '{"domain_address":"mail.example.com","name":"Example mail domain"}', + overrides: { + skipAuth: true, + headers: { + 'X-Custom': 'value', + ...signingHeaders, + }, + }, }); }); }); @@ -129,8 +311,45 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'PUT', path: '/v3/admin/domains/domain123', + queryParams: undefined, body: requestBody, - overrides: signedOverrides({ override: 'bar' }), + serializedBody: undefined, + overrides: expectedSignedOverrides({ override: 'bar' }), + }); + }); + + it('should sign update requests with the exact PUT path and canonical body', async () => { + const requestBody = { + name: 'Renamed domain', + }; + signer.buildHeaders.mockReturnValue({ + headers: signingHeaders, + serializedBody: '{"name":"Renamed domain"}', + }); + + await domains.update({ + domainId: 'domain123', + requestBody, + signer, + }); + + expect(signer.buildHeaders).toHaveBeenCalledWith({ + method: 'PUT', + path: '/v3/admin/domains/domain123', + body: { + name: 'Renamed domain', + }, + }); + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/v3/admin/domains/domain123', + queryParams: undefined, + body: requestBody, + serializedBody: '{"name":"Renamed domain"}', + overrides: { + skipAuth: true, + headers: signingHeaders, + }, }); }); }); @@ -145,7 +364,7 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'DELETE', path: '/v3/admin/domains/domain123', - overrides: signedOverrides({ override: 'bar' }), + overrides: expectedSignedOverrides({ override: 'bar' }), }); }); }); @@ -165,8 +384,10 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/domain123/info', + queryParams: undefined, body: requestBody, - overrides: signedOverrides({ override: 'bar' }), + serializedBody: undefined, + overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -185,8 +406,47 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/domain123/info', + queryParams: undefined, body: requestBody, - overrides: signedOverrides(), + serializedBody: undefined, + overrides: expectedSignedOverrides(), + }); + }); + + it('should sign info requests with a canonical snake_case body', async () => { + const requestBody = { + type: 'dkim' as const, + options: { keyLength: 2048 }, + }; + signer.buildHeaders.mockReturnValue({ + headers: signingHeaders, + serializedBody: '{"options":{"key_length":2048},"type":"dkim"}', + }); + + await domains.info({ + domainId: 'domain123', + requestBody, + signer, + }); + + expect(signer.buildHeaders).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/info', + body: { + type: 'dkim', + options: { key_length: 2048 }, + }, + }); + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/info', + queryParams: undefined, + body: requestBody, + serializedBody: '{"options":{"key_length":2048},"type":"dkim"}', + overrides: { + skipAuth: true, + headers: signingHeaders, + }, }); }); }); @@ -206,8 +466,10 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/domain123/verify', + queryParams: undefined, body: requestBody, - overrides: signedOverrides({ override: 'bar' }), + serializedBody: undefined, + overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -225,8 +487,10 @@ describe('Domains', () => { expect(apiClient.request).toHaveBeenCalledWith({ method: 'POST', path: '/v3/admin/domains/mail.example.com/verify', + queryParams: undefined, body: requestBody, - overrides: signedOverrides(), + serializedBody: undefined, + overrides: expectedSignedOverrides(), }); }); }); From 4c444d46759f6ac58ffbed1d0efc3b1614fcf9c8 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:44:02 -0400 Subject: [PATCH 08/13] Cover service account signer edge cases --- tests/models/serviceAccount.spec.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/models/serviceAccount.spec.ts b/tests/models/serviceAccount.spec.ts index a54fb887..7276f9a3 100644 --- a/tests/models/serviceAccount.spec.ts +++ b/tests/models/serviceAccount.spec.ts @@ -1,6 +1,7 @@ import { createVerify, generateKeyPairSync } from 'node:crypto'; import { canonicalJson, + generateNonce, ServiceAccountSigner, } from '../../src/models/serviceAccount'; @@ -12,6 +13,10 @@ describe('ServiceAccountSigner', () => { type: 'pkcs1', format: 'pem', }) as string; + const pkcs8PrivateKeyPem = privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; it('should serialize JSON canonically with sorted keys', () => { expect( @@ -23,6 +28,66 @@ describe('ServiceAccountSigner', () => { ).toEqual('{"a":{"x":"value","y":true},"list":[{"a":1,"b":2}],"z":1}'); }); + it('should omit undefined and function object fields while preserving array slots', () => { + expect( + canonicalJson({ + kept: 'value', + omitted: undefined, + ignored: () => 'value', + list: [undefined, () => 'value', 'ok'], + }) + ).toEqual('{"kept":"value","list":[null,null,"ok"]}'); + }); + + it('should reject non-finite numbers', () => { + expect(() => canonicalJson({ value: Number.NaN })).toThrow( + 'Service account signing only supports finite numbers.' + ); + }); + + it('should generate secure alphanumeric nonces', () => { + const nonce = generateNonce(24); + + expect(nonce).toHaveLength(24); + expect(nonce).toMatch(/^[A-Za-z0-9]+$/); + expect(() => generateNonce(0)).toThrow( + 'Nonce length must be a positive integer.' + ); + }); + + it('should accept base64-encoded PEM private keys', () => { + const signer = new ServiceAccountSigner({ + privateKeyPem: Buffer.from(pkcs8PrivateKeyPem, 'utf8').toString('base64'), + privateKeyId: 'service-account-key-id', + }); + + const signed = signer.buildHeaders({ + method: 'GET', + path: '/v3/admin/domains', + timestamp: 1742932766, + nonce: 'nonce-1234567890123456', + }); + + expect(signed.headers['X-Nylas-Signature']).toEqual(expect.any(String)); + }); + + it('should reject non-RSA private keys', () => { + const { privateKey: ecPrivateKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + expect( + () => + new ServiceAccountSigner({ + privateKeyPem: ecPrivateKey.export({ + type: 'pkcs8', + format: 'pem', + }), + privateKeyId: 'service-account-key-id', + }) + ).toThrow('Service account private key must be RSA.'); + }); + it('should create service account signing headers', () => { const signer = new ServiceAccountSigner({ privateKeyPem, From 3beeeb1e9a7bceb7a95c19996d642516114f3484 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:01:03 -0400 Subject: [PATCH 09/13] Canonicalize signed domain request bodies --- CHANGELOG.md | 2 +- src/resources/domains.ts | 11 +++++++++-- tests/resources/domains.spec.ts | 13 +++++++------ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd4f93b..070cc058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Workspaces API support via `nylas.workspaces` — list, find, create, update (PATCH), destroy, plus `autoGroup()` and `manualAssign()` for grouping grants by domain, `default`, `policyId`, and `ruleIds` - Add Agent Account Lists API support via `nylas.lists` — create lists with `name`, optional `description`, and immutable `type`, plus list, find, update, destroy, `listItems()`, `addItems()`, and `removeItems()` for managing `/v3/lists` -- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`). Includes `ServiceAccountSigner` support for Nylas Service Account request signing. +- Add Manage Domains API support via `nylas.domains` — list, find, create, update, destroy, plus `info()` and `verify()` for domain verification (`/v3/admin/domains`). Includes `ServiceAccountSigner` support for Nylas Service Account request signing, bearer-auth suppression, and canonical signed wire bodies. ### Fixed - Correct `Policies` `PolicyLimits` to expose `limitCountDailyMessageReceived` and `limitCountDailyEmailSent` (replacing a non-existent per-grant field) diff --git a/src/resources/domains.ts b/src/resources/domains.ts index 6bc3ccc7..fa138c1f 100644 --- a/src/resources/domains.ts +++ b/src/resources/domains.ts @@ -12,7 +12,10 @@ import { NylasListResponse, NylasResponse, } from '../models/response.js'; -import { ServiceAccountSigner } from '../models/serviceAccount.js'; +import { + canonicalJson, + ServiceAccountSigner, +} from '../models/serviceAccount.js'; import { makePathParams, objKeysToSnakeCase } from '../utils.js'; import { AsyncListResponse, Resource } from './resource.js'; @@ -141,7 +144,11 @@ export class Domains extends Resource { } { if (!signer) { this.assertServiceAccountSigningHeaders(overrides); - return { overrides: { ...overrides, skipAuth: true } }; + const body = requestBody ? objKeysToSnakeCase(requestBody) : undefined; + return { + overrides: { ...overrides, skipAuth: true }, + serializedBody: body ? canonicalJson(body) : undefined, + }; } const body = requestBody ? objKeysToSnakeCase(requestBody) : undefined; diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts index 29d5fc57..efa88420 100644 --- a/tests/resources/domains.spec.ts +++ b/tests/resources/domains.spec.ts @@ -247,7 +247,8 @@ describe('Domains', () => { path: '/v3/admin/domains', queryParams: undefined, body: requestBody, - serializedBody: undefined, + serializedBody: + '{"domain_address":"mail.example.com","name":"Example mail domain"}', overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -313,7 +314,7 @@ describe('Domains', () => { path: '/v3/admin/domains/domain123', queryParams: undefined, body: requestBody, - serializedBody: undefined, + serializedBody: '{"name":"Renamed domain"}', overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -386,7 +387,7 @@ describe('Domains', () => { path: '/v3/admin/domains/domain123/info', queryParams: undefined, body: requestBody, - serializedBody: undefined, + serializedBody: '{"type":"ownership"}', overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -408,7 +409,7 @@ describe('Domains', () => { path: '/v3/admin/domains/domain123/info', queryParams: undefined, body: requestBody, - serializedBody: undefined, + serializedBody: '{"options":{"key_length":2048},"type":"dkim"}', overrides: expectedSignedOverrides(), }); }); @@ -468,7 +469,7 @@ describe('Domains', () => { path: '/v3/admin/domains/domain123/verify', queryParams: undefined, body: requestBody, - serializedBody: undefined, + serializedBody: '{"type":"spf"}', overrides: expectedSignedOverrides({ override: 'bar' }), }); }); @@ -489,7 +490,7 @@ describe('Domains', () => { path: '/v3/admin/domains/mail.example.com/verify', queryParams: undefined, body: requestBody, - serializedBody: undefined, + serializedBody: '{"type":"arc"}', overrides: expectedSignedOverrides(), }); }); From 393059c0e1a6ccba2db1b4cf87ab1f632424e095 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:48:45 -0400 Subject: [PATCH 10/13] Split domain verification request types --- src/models/domains.ts | 13 ++++++++++++- tests/resources/domains.spec.ts | 20 -------------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/models/domains.ts b/src/models/domains.ts index 47d05737..7a289b48 100644 --- a/src/models/domains.ts +++ b/src/models/domains.ts @@ -12,6 +12,17 @@ export type DomainVerificationType = | 'dmarc' | 'arc'; +/** + * Type for the DNS verification types accepted by Manage Domains info and + * verify request bodies. + */ +export type DomainVerificationRequestType = + | 'ownership' + | 'mx' + | 'spf' + | 'dkim' + | 'feedback'; + /** * Type for the status of a domain DNS verification attempt. */ @@ -124,7 +135,7 @@ export interface DomainVerificationAttempt { /** * The DNS verification type to fetch info for or verify. */ - type: DomainVerificationType; + type: DomainVerificationRequestType; /** * Free-form options. For dkim, may carry a key-length hint. Most callers omit this. */ diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts index efa88420..034b4fe9 100644 --- a/tests/resources/domains.spec.ts +++ b/tests/resources/domains.spec.ts @@ -474,25 +474,5 @@ describe('Domains', () => { }); }); - it('should accept the extended dmarc/arc verification types', async () => { - const requestBody = { - type: 'arc' as const, - }; - - await domains.verify({ - domainId: 'mail.example.com', - requestBody, - overrides: signedOverrides(), - }); - - expect(apiClient.request).toHaveBeenCalledWith({ - method: 'POST', - path: '/v3/admin/domains/mail.example.com/verify', - queryParams: undefined, - body: requestBody, - serializedBody: '{"type":"arc"}', - overrides: expectedSignedOverrides(), - }); - }); }); }); From 64430f933d7b205e9e06b91b98c33f025cf065fe Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:51:04 -0400 Subject: [PATCH 11/13] Format domains tests --- tests/resources/domains.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts index 034b4fe9..ebbb23bf 100644 --- a/tests/resources/domains.spec.ts +++ b/tests/resources/domains.spec.ts @@ -473,6 +473,5 @@ describe('Domains', () => { overrides: expectedSignedOverrides({ override: 'bar' }), }); }); - }); }); From 9e7e25ea56596cd024d5495074ad8535834bac55 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 12:04:17 -0400 Subject: [PATCH 12/13] Remove unsupported domain list filters --- src/models/domains.ts | 11 +---------- tests/resources/domains.spec.ts | 6 ------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/models/domains.ts b/src/models/domains.ts index 7a289b48..417f2e57 100644 --- a/src/models/domains.ts +++ b/src/models/domains.ts @@ -196,13 +196,4 @@ export interface DomainVerificationResult { /** * Interface representing query parameters for listing domains. */ -export interface ListDomainsQueryParams extends ListQueryParams { - /** - * Filter by exact domain address. Note the key is `domain`, not `domainAddress`. - */ - domain?: string; - /** - * Filter by region. - */ - region?: string; -} +export interface ListDomainsQueryParams extends ListQueryParams {} diff --git a/tests/resources/domains.spec.ts b/tests/resources/domains.spec.ts index ebbb23bf..6e60d859 100644 --- a/tests/resources/domains.spec.ts +++ b/tests/resources/domains.spec.ts @@ -179,21 +179,15 @@ describe('Domains', () => { await domains.list({ queryParams: { limit: 10, - domain: 'mail.example.com', - region: 'us', }, overrides: signedOverrides({ override: 'bar' }), }); - // Path must target the public admin surface, and the address filter key - // is `domain` (not `domainAddress`). expect(apiClient.request).toHaveBeenCalledWith({ method: 'GET', path: '/v3/admin/domains', queryParams: { limit: 10, - domain: 'mail.example.com', - region: 'us', }, overrides: expectedSignedOverrides({ override: 'bar' }), }); From 4eda3b6ecd2ae4e89392ffa2ffdb6366d8740a28 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 12:10:56 -0400 Subject: [PATCH 13/13] Use type alias for domain list params --- src/models/domains.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/domains.ts b/src/models/domains.ts index 417f2e57..a89350d3 100644 --- a/src/models/domains.ts +++ b/src/models/domains.ts @@ -196,4 +196,4 @@ export interface DomainVerificationResult { /** * Interface representing query parameters for listing domains. */ -export interface ListDomainsQueryParams extends ListQueryParams {} +export type ListDomainsQueryParams = ListQueryParams;