diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd8f2e1..070cc058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,22 @@ 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 +## [Unreleased] + +### 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, bearer-auth suppression, and canonical signed wire bodies. + +### 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 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` + +## [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)) 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/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/applicationDetails.ts b/src/models/applicationDetails.ts index 9d6456af..bdc9b5fb 100644 --- a/src/models/applicationDetails.ts +++ b/src/models/applicationDetails.ts @@ -13,13 +13,17 @@ export interface ApplicationDetails { */ organizationId: string; /** - * Region identifier + * Region identifier (e.g. `us`, `eu`). Free-form string, not a closed enum. */ - region: 'us' | 'eu'; + region: string; /** - * Environment identifier + * Environment identifier (e.g. `sandbox`). Free-form string, not a closed enum. */ - environment: 'production' | 'staging'; + environment: string; + /** + * White-label domain. Omitted when empty. + */ + domain?: string; /** * Branding details for the application */ @@ -29,15 +33,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. + */ + createdAt?: number; + /** + * Unix timestamp (seconds) when the application was last updated. Omitted when empty. + */ + updatedAt?: number; + /** + * Whether the application is blocked. Omitted when empty. */ - redirectUris?: RedirectUri[]; + blocked?: boolean; } /** * Interface for branding details for the application */ -interface Branding { +export interface Branding { /** * Name of the application */ @@ -51,7 +74,7 @@ interface Branding { */ websiteUrl?: string; /** - * Description of the appli∏cati∏on + * Description of the application */ description?: string; } @@ -59,7 +82,7 @@ interface Branding { /** * Interface for hosted authentication branding details */ -interface HostedAuthentication { +export interface HostedAuthentication { /** * URL of the background image */ @@ -92,4 +115,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..a89350d3 --- /dev/null +++ b/src/models/domains.ts @@ -0,0 +1,199 @@ +import { ListQueryParams } from './listQueryParams.js'; + +/** + * Type for the DNS verification types supported by the Manage Domains API. + */ +export type DomainVerificationType = + | 'ownership' + | 'mx' + | 'spf' + | 'dkim' + | 'feedback' + | '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. + */ +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; + /** + * 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, and timestamps. + */ +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 `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; +} + +/** + * 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: DomainVerificationRequestType; + /** + * 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 type ListDomainsQueryParams = ListQueryParams; diff --git a/src/models/index.ts b/src/models/index.ts index 140e746c..5803a387 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'; @@ -22,6 +23,8 @@ 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'; +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/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/models/workspaces.ts b/src/models/workspaces.ts new file mode 100644 index 00000000..95468f20 --- /dev/null +++ b/src/models/workspaces.ts @@ -0,0 +1,198 @@ +/** + * 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; + /** + * When true, this is the application's default workspace. + */ + default?: boolean; + /** + * The ID of the inbox policy attached to the workspace. + */ + policyId?: string; + /** + * The IDs of the inbox rules attached to the workspace. + */ + ruleIds?: 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. + */ + ruleIds?: 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. + */ + ruleIds?: 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..fa138c1f --- /dev/null +++ b/src/resources/domains.ts @@ -0,0 +1,389 @@ +import { Overrides, OverridableNylasConfig } from '../config.js'; +import { + CreateDomainRequest, + Domain, + DomainVerificationAttempt, + DomainVerificationResult, + ListDomainsQueryParams, + UpdateDomainRequest, +} from '../models/domains.js'; +import { + NylasBaseResponse, + NylasListResponse, + NylasResponse, +} from '../models/response.js'; +import { + canonicalJson, + ServiceAccountSigner, +} from '../models/serviceAccount.js'; +import { makePathParams, objKeysToSnakeCase } from '../utils.js'; +import { AsyncListResponse, Resource } from './resource.js'; + +/** + * @property queryParams The query parameters to include in the request. + */ +interface ListDomainsParams { + queryParams?: ListDomainsQueryParams; + signer?: ServiceAccountSigner; +} + +/** + * @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; + signer?: ServiceAccountSigner; +} + +/** + * @property requestBody The values to create the domain with. + */ +interface CreateDomainParams { + requestBody: CreateDomainRequest; + signer?: ServiceAccountSigner; +} + +/** + * @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; + signer?: ServiceAccountSigner; +} + +/** + * @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; + signer?: ServiceAccountSigner; +} + +/** + * @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; + signer?: ServiceAccountSigner; +} + +/** + * @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; + signer?: ServiceAccountSigner; +} + +interface SignedRequestParams { + method: string; + path: string; + requestBody?: Record; + signer?: ServiceAccountSigner; + overrides?: OverridableNylasConfig; +} + +/** + * 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 { + 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.' + ); + } + } + + private buildSignedRequest({ + method, + path, + requestBody, + signer, + overrides, + }: SignedRequestParams): { + overrides?: OverridableNylasConfig; + serializedBody?: string; + } { + if (!signer) { + this.assertServiceAccountSigningHeaders(overrides); + const body = requestBody ? objKeysToSnakeCase(requestBody) : undefined; + return { + overrides: { ...overrides, skipAuth: true }, + serializedBody: body ? canonicalJson(body) : undefined, + }; + } + + 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. + * + * 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({ + queryParams, + signer, + overrides, + }: ListDomainsParams & Overrides = {}): AsyncListResponse< + NylasListResponse + > { + 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, + overrides: signed.overrides, + }); + } + + /** + * 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, + signer, + overrides, + }: FindDomainParams & Overrides): Promise> { + const path = makePathParams('/v3/admin/domains/{domainId}', { domainId }); + const signed = this.buildSignedRequest({ + method: 'GET', + path, + signer, + overrides, + }); + return super._find({ + path, + overrides: signed.overrides, + }); + } + + /** + * 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, + signer, + overrides, + }: CreateDomainParams & Overrides): Promise> { + 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, + }); + } + + /** + * 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. + * + * 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({ + domainId, + requestBody, + signer, + overrides, + }: UpdateDomainParams & Overrides): Promise> { + 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, + }); + } + + /** + * 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, + signer, + overrides, + }: DestroyDomainParams & Overrides): Promise { + const path = makePathParams('/v3/admin/domains/{domainId}', { domainId }); + const signed = this.buildSignedRequest({ + method: 'DELETE', + path, + signer, + overrides, + }); + return super._destroy({ + path, + overrides: signed.overrides, + }); + } + + /** + * 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({ + domainId, + requestBody, + signer, + overrides, + }: InfoDomainParams & Overrides): Promise< + NylasResponse + > { + 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, + }); + } + + /** + * 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({ + domainId, + requestBody, + signer, + overrides, + }: VerifyDomainParams & Overrides): Promise< + NylasResponse + > { + 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/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..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,14 +57,31 @@ 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: + // { data: { items: T[], nextCursor? } }. + // This guard remaps that nested shape back to the flat shape the list machinery + // and public SDK surface expect. + 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; @@ -81,7 +100,7 @@ export class Resource { limit: entriesRemaining, pageToken: res.nextCursor, }, - overrides, + overrides: getOverrides ? getOverrides() : overrides, }); res.data = res.data.concat(nextRes.data); @@ -156,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/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/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..7276f9a3 --- /dev/null +++ b/tests/models/serviceAccount.spec.ts @@ -0,0 +1,152 @@ +import { createVerify, generateKeyPairSync } from 'node:crypto'; +import { + canonicalJson, + generateNonce, + ServiceAccountSigner, +} from '../../src/models/serviceAccount'; + +describe('ServiceAccountSigner', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const privateKeyPem = privateKey.export({ + 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( + 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 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, + 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/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/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..6e60d859 --- /dev/null +++ b/tests/resources/domains.spec.ts @@ -0,0 +1,471 @@ +import APIClient from '../../src/apiClient'; +import { ServiceAccountSigner } from '../../src/models/serviceAccount'; +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 }, +}); + +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; + + 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: [] }); + signer.buildHeaders.mockReset(); + signer.buildHeaders.mockReturnValue({ headers: signingHeaders }); + }); + + 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(); + }); + + 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({ + queryParams: { + limit: 10, + }, + overrides: signedOverrides({ override: 'bar' }), + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains', + queryParams: { + limit: 10, + }, + overrides: expectedSignedOverrides({ override: 'bar' }), + }); + }); + }); + + describe('find', () => { + it('should call apiClient.request with the correct params', async () => { + await domains.find({ + domainId: 'domain123', + overrides: signedOverrides({ override: 'bar' }), + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/admin/domains/domain123', + queryParams: undefined, + overrides: expectedSignedOverrides({ 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', + queryParams: undefined, + overrides: expectedSignedOverrides(), + }); + }); + }); + + 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: signedOverrides({ override: 'bar' }), + }); + + 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: 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, + }, + }, + }); + }); + }); + + describe('update', () => { + it('should call apiClient.request with the correct params using PUT', async () => { + const requestBody = { + name: 'Renamed domain', + }; + + await domains.update({ + domainId: 'domain123', + requestBody, + overrides: signedOverrides({ override: 'bar' }), + }); + + // Update is PUT (not PATCH) and targets the admin surface. + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/v3/admin/domains/domain123', + queryParams: undefined, + body: requestBody, + serializedBody: '{"name":"Renamed domain"}', + 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, + }, + }); + }); + }); + + describe('destroy', () => { + it('should call apiClient.request with the correct params', async () => { + await domains.destroy({ + domainId: 'domain123', + overrides: signedOverrides({ override: 'bar' }), + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/v3/admin/domains/domain123', + overrides: expectedSignedOverrides({ 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: signedOverrides({ override: 'bar' }), + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/info', + queryParams: undefined, + body: requestBody, + serializedBody: '{"type":"ownership"}', + overrides: expectedSignedOverrides({ 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, + overrides: signedOverrides(), + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/info', + queryParams: undefined, + body: requestBody, + serializedBody: '{"options":{"key_length":2048},"type":"dkim"}', + 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, + }, + }); + }); + }); + + 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: signedOverrides({ override: 'bar' }), + }); + + expect(apiClient.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/v3/admin/domains/domain123/verify', + queryParams: undefined, + body: requestBody, + serializedBody: '{"type":"spf"}', + overrides: expectedSignedOverrides({ override: 'bar' }), + }); + }); + }); +}); 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..072e44f7 100644 --- a/tests/resources/rules.spec.ts +++ b/tests/resources/rules.spec.ts @@ -43,6 +43,80 @@ describe('Rules', () => { }, }); }); + + // 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 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 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'); + }); + + 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 () => { + 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 +177,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 +299,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..afad6ffe --- /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', + ruleIds: ['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, + ruleIds: ['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' }, + }, + }); + }); + }); +});