From 223dc694003cd84c245d235168b3380014b44c65 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:13:46 -0400 Subject: [PATCH 01/23] TW-5374 Add missing admin API resources --- CHANGELOG.md | 7 +- src/main/kotlin/com/nylas/NylasClient.kt | 6 + .../com/nylas/models/CreateDomainRequest.kt | 13 + .../nylas/models/CreateWorkspaceRequest.kt | 32 +++ src/main/kotlin/com/nylas/models/Domain.kt | 45 ++++ .../com/nylas/models/DomainVerification.kt | 103 ++++++++ .../nylas/models/ListDomainsQueryParams.kt | 30 +++ .../models/ListRuleEvaluationsQueryParams.kt | 22 ++ .../kotlin/com/nylas/models/RuleEvaluation.kt | 87 +++++++ .../com/nylas/models/RulesListResponse.kt | 27 ++ .../nylas/models/UpdateApplicationRequest.kt | 48 ++++ .../com/nylas/models/UpdateDomainRequest.kt | 42 ++++ .../nylas/models/UpdateWorkspaceRequest.kt | 34 +++ src/main/kotlin/com/nylas/models/Workspace.kt | 27 ++ .../nylas/models/WorkspaceAutoGroupRequest.kt | 26 ++ .../models/WorkspaceAutoGroupResponse.kt | 13 + .../models/WorkspaceManualAssignRequest.kt | 22 ++ .../models/WorkspaceManualAssignResponse.kt | 19 ++ .../com/nylas/resources/Applications.kt | 16 ++ .../kotlin/com/nylas/resources/Domains.kt | 112 +++++++++ src/main/kotlin/com/nylas/resources/Rules.kt | 28 ++- .../kotlin/com/nylas/resources/Workspaces.kt | 121 +++++++++ .../com/nylas/resources/ApplicationsTests.kt | 27 ++ .../com/nylas/resources/DomainsTests.kt | 155 ++++++++++++ .../kotlin/com/nylas/resources/RulesTests.kt | 102 +++++++- .../com/nylas/resources/WorkspacesTests.kt | 237 ++++++++++++++++++ 26 files changed, 1396 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/com/nylas/models/CreateDomainRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/Domain.kt create mode 100644 src/main/kotlin/com/nylas/models/DomainVerification.kt create mode 100644 src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt create mode 100644 src/main/kotlin/com/nylas/models/ListRuleEvaluationsQueryParams.kt create mode 100644 src/main/kotlin/com/nylas/models/RuleEvaluation.kt create mode 100644 src/main/kotlin/com/nylas/models/RulesListResponse.kt create mode 100644 src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/Workspace.kt create mode 100644 src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/WorkspaceAutoGroupResponse.kt create mode 100644 src/main/kotlin/com/nylas/models/WorkspaceManualAssignRequest.kt create mode 100644 src/main/kotlin/com/nylas/models/WorkspaceManualAssignResponse.kt create mode 100644 src/main/kotlin/com/nylas/resources/Workspaces.kt create mode 100644 src/test/kotlin/com/nylas/resources/WorkspacesTests.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2d505c..d9d9401e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,13 @@ - Examples: `TransactionalEmailExample.java` and `KotlinTransactionalEmailExample.kt` * Administration API — Policies, Rules, and Lists (app-level, `nylas` provider only) - `Policies` resource via `client.policies()`: full CRUD (`list`, `find`, `create`, `update`, `destroy`) with `CreatePolicyRequest` / `UpdatePolicyRequest` and supporting models (`Policy`, `PolicyLimits`, `PolicyOptions`, `PolicySpamDetection`) - - `Rules` resource via `client.rules()`: full CRUD with `CreateRuleRequest` / `UpdateRuleRequest` and supporting models (`Rule`, `RuleAction`, `RuleActionType`, `RuleCondition`, `RuleConditionOperator`, `RuleMatch`, `RuleMatchOperator`, `RuleTrigger`) + - `Rules` resource via `client.rules()`: full CRUD plus `listEvaluations` for grant rule-evaluation audit records; handles the nested `/v3/rules` list envelope returned by the API - `NylasLists` resource via `client.lists()`: full CRUD plus `listItems`, `addItems`, and `removeItems` for managing list contents; `NylasList`, `NylasListItem`, `NylasListType`, `ListItemsRequest` models +* Application administration updates + - `Applications.update()` for `PATCH /v3/applications` + - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` + - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains` + - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, and `manualAssign` ## [v2.16.1] - Release 2026-05-21 diff --git a/src/main/kotlin/com/nylas/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index 38472bb0..bb60b668 100644 --- a/src/main/kotlin/com/nylas/NylasClient.kt +++ b/src/main/kotlin/com/nylas/NylasClient.kt @@ -183,6 +183,12 @@ open class NylasClient( */ open fun rules(): Rules = Rules(this) + /** + * Access the Workspaces API + * @return The Workspaces API + */ + open fun workspaces(): Workspaces = Workspaces(this) + /** * Access the Lists API * @return The Lists API diff --git a/src/main/kotlin/com/nylas/models/CreateDomainRequest.kt b/src/main/kotlin/com/nylas/models/CreateDomainRequest.kt new file mode 100644 index 00000000..de8e5f0e --- /dev/null +++ b/src/main/kotlin/com/nylas/models/CreateDomainRequest.kt @@ -0,0 +1,13 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas create domain request. + */ +data class CreateDomainRequest( + @Json(name = "name") + val name: String, + @Json(name = "domain_address") + val domainAddress: String, +) diff --git a/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt new file mode 100644 index 00000000..38a182c1 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt @@ -0,0 +1,32 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas create workspace request. + */ +data class CreateWorkspaceRequest( + @Json(name = "name") + val name: String, + @Json(name = "domain") + val domain: String? = null, + @Json(name = "auto_group") + val autoGroup: Boolean? = null, + @Json(name = "policy_id") + val policyId: String? = null, + @Json(name = "rules_ids") + val rulesIds: List? = null, +) { + data class Builder(private val name: String) { + private var domain: String? = null + private var autoGroup: Boolean? = null + private var policyId: String? = null + private var rulesIds: List? = null + + fun domain(domain: String?) = apply { this.domain = domain } + fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } + fun policyId(policyId: String?) = apply { this.policyId = policyId } + fun rulesIds(rulesIds: List?) = apply { this.rulesIds = rulesIds } + fun build() = CreateWorkspaceRequest(name, domain, autoGroup, policyId, rulesIds) + } +} diff --git a/src/main/kotlin/com/nylas/models/Domain.kt b/src/main/kotlin/com/nylas/models/Domain.kt new file mode 100644 index 00000000..3451823c --- /dev/null +++ b/src/main/kotlin/com/nylas/models/Domain.kt @@ -0,0 +1,45 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas managed email domain. + */ +data class Domain( + @Json(name = "id") + val id: String? = null, + @Json(name = "name") + val name: String? = null, + @Json(name = "domain_address") + val domainAddress: String? = null, + @Json(name = "organization_id") + val organizationId: String? = null, + @Json(name = "branded") + val branded: Boolean? = null, + @Json(name = "region") + val region: String? = null, + @Json(name = "tenant_key") + val tenantKey: String? = null, + @Json(name = "dkim_public_key") + val dkimPublicKey: String? = null, + @Json(name = "dkim_submitted_at") + val dkimSubmittedAt: Long? = null, + @Json(name = "verified_ownership") + val verifiedOwnership: Boolean? = null, + @Json(name = "verified_mx") + val verifiedMx: Boolean? = null, + @Json(name = "verified_spf") + val verifiedSpf: Boolean? = null, + @Json(name = "verified_feedback") + val verifiedFeedback: Boolean? = null, + @Json(name = "verified_dkim") + val verifiedDkim: Boolean? = null, + @Json(name = "verified_dmarc") + val verifiedDmarc: Boolean? = null, + @Json(name = "verified_arc") + val verifiedArc: Boolean? = null, + @Json(name = "created_at") + val createdAt: Long? = null, + @Json(name = "updated_at") + val updatedAt: Long? = null, +) diff --git a/src/main/kotlin/com/nylas/models/DomainVerification.kt b/src/main/kotlin/com/nylas/models/DomainVerification.kt new file mode 100644 index 00000000..87430c91 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/DomainVerification.kt @@ -0,0 +1,103 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * DNS verification types accepted by the Manage Domains API. + */ +enum class DomainVerificationType { + @Json(name = "ownership") + OWNERSHIP, + + @Json(name = "mx") + MX, + + @Json(name = "spf") + SPF, + + @Json(name = "dkim") + DKIM, + + @Json(name = "feedback") + FEEDBACK, + + @Json(name = "dmarc") + DMARC, + + @Json(name = "arc") + ARC, +} + +/** + * Status values returned by domain DNS verification attempts. + */ +enum class DomainVerificationStatus { + @Json(name = "pending") + PENDING, + + @Json(name = "done") + DONE, + + @Json(name = "failed") + FAILED, +} + +/** + * Class representation of a domain DNS verification request. + */ +data class DomainVerificationRequest( + @Json(name = "type") + val type: DomainVerificationType, + @Json(name = "options") + val options: Map? = null, +) { + /** + * Builder for [DomainVerificationRequest]. + */ + data class Builder(private val type: DomainVerificationType) { + private var options: Map? = null + + /** + * Set verification options. + * @param options Verification options. + * @return This builder. + */ + fun options(options: Map?) = apply { this.options = options } + + /** + * Build the [DomainVerificationRequest]. + * @return The built [DomainVerificationRequest]. + */ + fun build() = DomainVerificationRequest(type, options) + } +} + +/** + * Class representation of a verification attempt returned by the API. + */ +data class DomainVerificationAttempt( + @Json(name = "type") + val type: DomainVerificationType? = null, + @Json(name = "options") + val options: Map? = null, +) + +/** + * Class representation of a domain verification result. + */ +data class DomainVerificationResult( + @Json(name = "domain_id") + val domainId: String? = null, + @Json(name = "attempt") + val attempt: DomainVerificationAttempt? = null, + @Json(name = "status") + val status: DomainVerificationStatus? = null, + @Json(name = "created_at") + val createdAt: Long? = null, + @Json(name = "expires_at") + val expiresAt: Long? = null, + @Json(name = "details") + val details: Map? = null, + @Json(name = "message") + val message: String? = null, +) diff --git a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt new file mode 100644 index 00000000..a4fe0d9b --- /dev/null +++ b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt @@ -0,0 +1,30 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of the query parameters for listing domains. + */ +data class ListDomainsQueryParams( + @Json(name = "domain") + val domain: String? = null, + @Json(name = "region") + val region: String? = null, + @Json(name = "limit") + val limit: Int? = null, + @Json(name = "page_token") + val pageToken: String? = null, +) : IQueryParams { + class Builder { + private var domain: String? = null + private var region: String? = null + private var limit: Int? = null + private var pageToken: String? = null + + fun domain(domain: String?) = apply { this.domain = domain } + fun region(region: String?) = apply { this.region = region } + fun limit(limit: Int?) = apply { this.limit = limit } + fun pageToken(pageToken: String?) = apply { this.pageToken = pageToken } + fun build() = ListDomainsQueryParams(domain, region, limit, pageToken) + } +} diff --git a/src/main/kotlin/com/nylas/models/ListRuleEvaluationsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListRuleEvaluationsQueryParams.kt new file mode 100644 index 00000000..a178bff7 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/ListRuleEvaluationsQueryParams.kt @@ -0,0 +1,22 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of the query parameters for listing rule evaluations. + */ +data class ListRuleEvaluationsQueryParams( + @Json(name = "limit") + val limit: Int? = null, + @Json(name = "page_token") + val pageToken: String? = null, +) : IQueryParams { + class Builder { + private var limit: Int? = null + private var pageToken: String? = null + + fun limit(limit: Int?) = apply { this.limit = limit } + fun pageToken(pageToken: String?) = apply { this.pageToken = pageToken } + fun build() = ListRuleEvaluationsQueryParams(limit, pageToken) + } +} diff --git a/src/main/kotlin/com/nylas/models/RuleEvaluation.kt b/src/main/kotlin/com/nylas/models/RuleEvaluation.kt new file mode 100644 index 00000000..169b3b8a --- /dev/null +++ b/src/main/kotlin/com/nylas/models/RuleEvaluation.kt @@ -0,0 +1,87 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * The stage where a rule evaluation happened. + */ +enum class RuleEvaluationStage { + @Json(name = "smtp_rcpt") + SMTP_RCPT, + + @Json(name = "inbox_processing") + INBOX_PROCESSING, + + @Json(name = "outbound_send") + OUTBOUND_SEND, +} + +/** + * Class representation of normalized rule evaluation input. + */ +data class RuleEvaluationInput( + @Json(name = "from_address") + val fromAddress: String? = null, + @Json(name = "from_domain") + val fromDomain: String? = null, + @Json(name = "from_tld") + val fromTld: String? = null, + @Json(name = "recipient_addresses") + val recipientAddresses: List? = null, + @Json(name = "recipient_domains") + val recipientDomains: List? = null, + @Json(name = "recipient_tlds") + val recipientTlds: List? = null, + @Json(name = "outbound_type") + val outboundType: String? = null, +) + +/** + * Class representation of actions applied during a rule evaluation. + */ +data class RuleEvaluationAppliedActions( + @Json(name = "blocked") + val blocked: Boolean? = null, + @Json(name = "marked_as_spam") + val markedAsSpam: Boolean? = null, + @Json(name = "marked_as_read") + val markedAsRead: Boolean? = null, + @Json(name = "marked_starred") + val markedStarred: Boolean? = null, + @Json(name = "archived") + val archived: Boolean? = null, + @Json(name = "trashed") + val trashed: Boolean? = null, + @Json(name = "folder_ids") + val folderIds: List? = null, +) + +/** + * Class representation of a rule evaluation audit record. + */ +data class RuleEvaluation( + @Json(name = "id") + val id: String? = null, + @Json(name = "grant_id") + val grantId: String? = null, + @Json(name = "message_id") + val messageId: String? = null, + @Json(name = "evaluated_at") + val evaluatedAt: Long? = null, + @Json(name = "evaluation_stage") + val evaluationStage: RuleEvaluationStage? = null, + @Json(name = "evaluation_input") + val evaluationInput: RuleEvaluationInput? = null, + @Json(name = "applied_actions") + val appliedActions: RuleEvaluationAppliedActions? = null, + @Json(name = "matched_rule_ids") + val matchedRuleIds: List? = null, + @Json(name = "application_id") + val applicationId: String? = null, + @Json(name = "organization_id") + val organizationId: String? = null, + @Json(name = "created_at") + val createdAt: Long? = null, + @Json(name = "updated_at") + val updatedAt: Long? = null, +) diff --git a/src/main/kotlin/com/nylas/models/RulesListResponse.kt b/src/main/kotlin/com/nylas/models/RulesListResponse.kt new file mode 100644 index 00000000..7f4aaafc --- /dev/null +++ b/src/main/kotlin/com/nylas/models/RulesListResponse.kt @@ -0,0 +1,27 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of the nested list response returned by the Rules API. + */ +data class RulesListResponse( + @Json(name = "data") + val data: RulesListData? = null, + @Json(name = "request_id") + val requestId: String = "", +) { + fun toListResponse(): ListResponse { + return ListResponse(data?.items ?: emptyList(), requestId, data?.nextCursor) + } +} + +/** + * Class representation of the nested Rules API list payload. + */ +data class RulesListData( + @Json(name = "items") + val items: List? = null, + @Json(name = "next_cursor") + val nextCursor: String? = null, +) diff --git a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt new file mode 100644 index 00000000..001390a8 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt @@ -0,0 +1,48 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas update application request. + */ +data class UpdateApplicationRequest( + /** + * Branding details for the application. + */ + @Json(name = "branding") + val branding: ApplicationDetails.Branding? = null, + /** + * Hosted authentication branding details. + */ + @Json(name = "hosted_authentication") + val hostedAuthentication: ApplicationDetails.HostedAuthentication? = null, +) { + /** + * Builder for [UpdateApplicationRequest]. + */ + class Builder { + private var branding: ApplicationDetails.Branding? = null + private var hostedAuthentication: ApplicationDetails.HostedAuthentication? = null + + /** + * Set branding details for the application. + * @param branding Branding details. + * @return This builder. + */ + fun branding(branding: ApplicationDetails.Branding?) = apply { this.branding = branding } + + /** + * Set hosted authentication branding details. + * @param hostedAuthentication Hosted authentication branding details. + * @return This builder. + */ + fun hostedAuthentication(hostedAuthentication: ApplicationDetails.HostedAuthentication?) = + apply { this.hostedAuthentication = hostedAuthentication } + + /** + * Build the [UpdateApplicationRequest]. + * @return The built [UpdateApplicationRequest]. + */ + fun build() = UpdateApplicationRequest(branding, hostedAuthentication) + } +} diff --git a/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt b/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt new file mode 100644 index 00000000..d591df1e --- /dev/null +++ b/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt @@ -0,0 +1,42 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas update domain request. + */ +data class UpdateDomainRequest( + @Json(name = "name") + val name: String? = null, + @Json(name = "region") + val region: String? = null, + @Json(name = "tenant_key") + val tenantKey: String? = null, + @Json(name = "dkim_public_key") + val dkimPublicKey: String? = null, + @Json(name = "dkim_submitted_at") + val dkimSubmittedAt: Long? = null, + @Json(name = "verified_feedback") + val verifiedFeedback: Boolean? = null, +) { + /** + * Builder for [UpdateDomainRequest]. + */ + class Builder { + private var name: String? = null + private var region: String? = null + private var tenantKey: String? = null + private var dkimPublicKey: String? = null + private var dkimSubmittedAt: Long? = null + private var verifiedFeedback: Boolean? = null + + fun name(name: String?) = apply { this.name = name } + fun region(region: String?) = apply { this.region = region } + fun tenantKey(tenantKey: String?) = apply { this.tenantKey = tenantKey } + fun dkimPublicKey(dkimPublicKey: String?) = apply { this.dkimPublicKey = dkimPublicKey } + fun dkimSubmittedAt(dkimSubmittedAt: Long?) = apply { this.dkimSubmittedAt = dkimSubmittedAt } + fun verifiedFeedback(verifiedFeedback: Boolean?) = apply { this.verifiedFeedback = verifiedFeedback } + + fun build() = UpdateDomainRequest(name, region, tenantKey, dkimPublicKey, dkimSubmittedAt, verifiedFeedback) + } +} diff --git a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt new file mode 100644 index 00000000..d5785293 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt @@ -0,0 +1,34 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas update workspace request. + */ +data class UpdateWorkspaceRequest( + @Json(name = "name") + val name: String? = null, + @Json(name = "domain") + val domain: String? = null, + @Json(name = "auto_group") + val autoGroup: Boolean? = null, + @Json(name = "policy_id") + val policyId: String? = null, + @Json(name = "rules_ids") + val rulesIds: List? = null, +) { + class Builder { + private var name: String? = null + private var domain: String? = null + private var autoGroup: Boolean? = null + private var policyId: String? = null + private var rulesIds: List? = null + + fun name(name: String?) = apply { this.name = name } + fun domain(domain: String?) = apply { this.domain = domain } + fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } + fun policyId(policyId: String?) = apply { this.policyId = policyId } + fun rulesIds(rulesIds: List?) = apply { this.rulesIds = rulesIds } + fun build() = UpdateWorkspaceRequest(name, domain, autoGroup, policyId, rulesIds) + } +} diff --git a/src/main/kotlin/com/nylas/models/Workspace.kt b/src/main/kotlin/com/nylas/models/Workspace.kt new file mode 100644 index 00000000..a0cc8086 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/Workspace.kt @@ -0,0 +1,27 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas workspace. + */ +data class Workspace( + @Json(name = "workspace_id") + val workspaceId: String, + @Json(name = "application_id") + val applicationId: String, + @Json(name = "name") + val name: String, + @Json(name = "domain") + val domain: String, + @Json(name = "auto_group") + val autoGroup: Boolean, + @Json(name = "policy_id") + val policyId: String? = null, + @Json(name = "rules_ids") + val rulesIds: List? = null, + @Json(name = "created_at") + val createdAt: Long, + @Json(name = "updated_at") + val updatedAt: Long, +) diff --git a/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt new file mode 100644 index 00000000..236cfd48 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt @@ -0,0 +1,26 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas workspace auto-group request. + */ +data class WorkspaceAutoGroupRequest( + @Json(name = "after_created_at") + val afterCreatedAt: Long? = null, + @Json(name = "invalid_also") + val invalidAlso: Boolean? = null, + @Json(name = "specific_domain") + val specificDomain: String? = null, +) { + class Builder { + private var afterCreatedAt: Long? = null + private var invalidAlso: Boolean? = null + private var specificDomain: String? = null + + fun afterCreatedAt(afterCreatedAt: Long?) = apply { this.afterCreatedAt = afterCreatedAt } + fun invalidAlso(invalidAlso: Boolean?) = apply { this.invalidAlso = invalidAlso } + fun specificDomain(specificDomain: String?) = apply { this.specificDomain = specificDomain } + fun build() = WorkspaceAutoGroupRequest(afterCreatedAt, invalidAlso, specificDomain) + } +} diff --git a/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupResponse.kt b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupResponse.kt new file mode 100644 index 00000000..558edb37 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupResponse.kt @@ -0,0 +1,13 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas workspace auto-group response. + */ +data class WorkspaceAutoGroupResponse( + @Json(name = "job_id") + val jobId: String, + @Json(name = "message") + val message: String, +) diff --git a/src/main/kotlin/com/nylas/models/WorkspaceManualAssignRequest.kt b/src/main/kotlin/com/nylas/models/WorkspaceManualAssignRequest.kt new file mode 100644 index 00000000..e3561aec --- /dev/null +++ b/src/main/kotlin/com/nylas/models/WorkspaceManualAssignRequest.kt @@ -0,0 +1,22 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas workspace manual assignment request. + */ +data class WorkspaceManualAssignRequest( + @Json(name = "assign_grants") + val assignGrants: List? = null, + @Json(name = "remove_grants") + val removeGrants: List? = null, +) { + class Builder { + private var assignGrants: List? = null + private var removeGrants: List? = null + + fun assignGrants(assignGrants: List?) = apply { this.assignGrants = assignGrants } + fun removeGrants(removeGrants: List?) = apply { this.removeGrants = removeGrants } + fun build() = WorkspaceManualAssignRequest(assignGrants, removeGrants) + } +} diff --git a/src/main/kotlin/com/nylas/models/WorkspaceManualAssignResponse.kt b/src/main/kotlin/com/nylas/models/WorkspaceManualAssignResponse.kt new file mode 100644 index 00000000..6d99d7f0 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/WorkspaceManualAssignResponse.kt @@ -0,0 +1,19 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of a Nylas workspace manual assignment response. + */ +data class WorkspaceManualAssignResponse( + @Json(name = "application_id") + val applicationId: String, + @Json(name = "workspace_id") + val workspaceId: String, + @Json(name = "domain") + val domain: String, + @Json(name = "grants_assigned") + val grantsAssigned: List? = null, + @Json(name = "grants_removed") + val grantsRemoved: List? = null, +) diff --git a/src/main/kotlin/com/nylas/resources/Applications.kt b/src/main/kotlin/com/nylas/resources/Applications.kt index 11981d99..216da7fc 100644 --- a/src/main/kotlin/com/nylas/resources/Applications.kt +++ b/src/main/kotlin/com/nylas/resources/Applications.kt @@ -2,6 +2,7 @@ package com.nylas.resources import com.nylas.NylasClient import com.nylas.models.* +import com.nylas.util.JsonHelper import com.squareup.moshi.Types /** @@ -30,4 +31,19 @@ class Applications(private val client: NylasClient) { val responseType = Types.newParameterizedType(Response::class.java, ApplicationDetails::class.java) return client.executeGet(path, responseType, overrides = overrides) } + + /** + * Update application details. + * @param requestBody The values to update the application with + * @param overrides Optional request overrides to apply + * @return The updated application details + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun update(requestBody: UpdateApplicationRequest, overrides: RequestOverrides? = null): Response { + val path = "v3/applications" + val responseType = Types.newParameterizedType(Response::class.java, ApplicationDetails::class.java) + val serializedRequestBody = JsonHelper.moshi().adapter(UpdateApplicationRequest::class.java).toJson(requestBody) + return client.executePatch(path, responseType, serializedRequestBody, overrides = overrides) + } } diff --git a/src/main/kotlin/com/nylas/resources/Domains.kt b/src/main/kotlin/com/nylas/resources/Domains.kt index c30b1895..839a6489 100644 --- a/src/main/kotlin/com/nylas/resources/Domains.kt +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -4,6 +4,7 @@ import com.nylas.NylasClient import com.nylas.models.* import com.nylas.util.FileUtils import com.nylas.util.JsonHelper +import com.nylas.util.PathEncoder import com.squareup.moshi.Types class Domains(client: NylasClient) : Resource(client, Message::class.java) { @@ -37,4 +38,115 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja createResource(path, serializedRequestBody, overrides = overrides) } } + + /** + * Return all managed domains for the caller's organization. + * @param queryParams Optional query parameters to apply + * @param overrides Optional request overrides to apply + * @return The list of managed domains + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun list(queryParams: ListDomainsQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { + val responseType = Types.newParameterizedType(ListResponse::class.java, Domain::class.java) + return client.executeGet("v3/admin/domains", responseType, queryParams, overrides) + } + + /** + * Return a managed domain. + * @param domainId The ID or domain address of the domain to retrieve + * @param overrides Optional request overrides to apply + * @return The managed domain + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun find(domainId: String, overrides: RequestOverrides? = null): Response { + val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) + val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) + return client.executeGet(path, responseType, overrides = overrides) + } + + /** + * Create a managed domain. + * @param requestBody The values to create the domain with + * @param overrides Optional request overrides to apply + * @return The created managed domain + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun create(requestBody: CreateDomainRequest, overrides: RequestOverrides? = null): Response { + val path = "v3/admin/domains" + val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) + val serializedRequestBody = JsonHelper.moshi().adapter(CreateDomainRequest::class.java).toJson(requestBody) + return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + } + + /** + * Update a managed domain. + * @param domainId The ID or domain address of the domain to update + * @param requestBody The values to update the domain with + * @param overrides Optional request overrides to apply + * @return The updated managed domain fields + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun update(domainId: String, requestBody: UpdateDomainRequest, overrides: RequestOverrides? = null): Response { + val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) + val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) + val serializedRequestBody = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java).toJson(requestBody) + return client.executePut(path, responseType, serializedRequestBody, overrides = overrides) + } + + /** + * Delete a managed domain. + * @param domainId The ID or domain address of the domain to delete + * @param overrides Optional request overrides to apply + * @return The deletion response + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun destroy(domainId: String, overrides: RequestOverrides? = null): DeleteResponse { + val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) + return client.executeDelete(path, DeleteResponse::class.java, overrides = overrides) + } + + /** + * Get DNS record information for a domain verification type. + * @param domainId The ID or domain address of the domain + * @param requestBody The verification type to inspect + * @param overrides Optional request overrides to apply + * @return The domain verification result + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun info( + domainId: String, + requestBody: DomainVerificationRequest, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/admin/domains/%s/info", PathEncoder.encode(domainId)) + val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) + val serializedRequestBody = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java).toJson(requestBody) + return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + } + + /** + * Trigger a DNS verification check for a domain verification type. + * @param domainId The ID or domain address of the domain + * @param requestBody The verification type to verify + * @param overrides Optional request overrides to apply + * @return The domain verification result + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun verify( + domainId: String, + requestBody: DomainVerificationRequest, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/admin/domains/%s/verify", PathEncoder.encode(domainId)) + val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) + val serializedRequestBody = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java).toJson(requestBody) + return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + } } diff --git a/src/main/kotlin/com/nylas/resources/Rules.kt b/src/main/kotlin/com/nylas/resources/Rules.kt index 290f7372..4c2fc390 100644 --- a/src/main/kotlin/com/nylas/resources/Rules.kt +++ b/src/main/kotlin/com/nylas/resources/Rules.kt @@ -4,6 +4,7 @@ import com.nylas.NylasClient import com.nylas.models.* import com.nylas.util.JsonHelper import com.nylas.util.PathEncoder +import com.squareup.moshi.Types /** * Nylas Rules API @@ -23,7 +24,13 @@ class Rules(client: NylasClient) : Resource(client, Rule::class.java) { @Throws(NylasApiError::class, NylasSdkTimeoutError::class) @JvmOverloads fun list(queryParams: ListRulesQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { - return listResource("v3/rules", queryParams, overrides) + val response = client.executeGet( + "v3/rules", + RulesListResponse::class.java, + queryParams, + overrides, + ) + return response.toListResponse() } /** @@ -79,4 +86,23 @@ class Rules(client: NylasClient) : Resource(client, Rule::class.java) { val path = String.format("v3/rules/%s", PathEncoder.encode(ruleId)) return destroyResource(path, overrides = overrides) } + + /** + * Return rule evaluation audit records for a grant. + * @param grantId The ID of the grant to query rule evaluations for + * @param queryParams Optional query parameters to apply + * @param overrides Optional request overrides to apply + * @return The list of rule evaluations + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun listEvaluations( + grantId: String, + queryParams: ListRuleEvaluationsQueryParams? = null, + overrides: RequestOverrides? = null, + ): ListResponse { + val path = String.format("v3/grants/%s/rule-evaluations", PathEncoder.encode(grantId)) + val responseType = Types.newParameterizedType(ListResponse::class.java, RuleEvaluation::class.java) + return client.executeGet(path, responseType, queryParams, overrides) + } } diff --git a/src/main/kotlin/com/nylas/resources/Workspaces.kt b/src/main/kotlin/com/nylas/resources/Workspaces.kt new file mode 100644 index 00000000..ca4eb515 --- /dev/null +++ b/src/main/kotlin/com/nylas/resources/Workspaces.kt @@ -0,0 +1,121 @@ +package com.nylas.resources + +import com.nylas.NylasClient +import com.nylas.models.* +import com.nylas.util.JsonHelper +import com.nylas.util.PathEncoder +import com.squareup.moshi.Types + +/** + * 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. + * + * @param client The configured Nylas API client + */ +class Workspaces(client: NylasClient) : Resource(client, Workspace::class.java) { + /** + * Return all workspaces for your application. + * @param overrides Optional request overrides to apply + * @return The list of workspaces + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun list(overrides: RequestOverrides? = null): ListResponse { + return listResource("v3/workspaces", overrides = overrides) + } + + /** + * Return a workspace. + * @param workspaceId The ID or domain address of the workspace to retrieve + * @param overrides Optional request overrides to apply + * @return The workspace + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun find(workspaceId: String, overrides: RequestOverrides? = null): Response { + val path = String.format("v3/workspaces/%s", PathEncoder.encode(workspaceId)) + return findResource(path, overrides = overrides) + } + + /** + * Create a workspace. + * @param requestBody The values to create the workspace with + * @param overrides Optional request overrides to apply + * @return The created workspace + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun create(requestBody: CreateWorkspaceRequest, overrides: RequestOverrides? = null): Response { + val serializedRequestBody = JsonHelper.moshi().adapter(CreateWorkspaceRequest::class.java).toJson(requestBody) + return createResource("v3/workspaces", serializedRequestBody, overrides = overrides) + } + + /** + * Update a workspace. + * @param workspaceId The workspace UUID to update + * @param requestBody The values to update the workspace with + * @param overrides Optional request overrides to apply + * @return The updated workspace + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun update(workspaceId: String, requestBody: UpdateWorkspaceRequest, overrides: RequestOverrides? = null): Response { + val path = String.format("v3/workspaces/%s", PathEncoder.encode(workspaceId)) + val serializedRequestBody = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java).toJson(requestBody) + return patchResource(path, serializedRequestBody, overrides = overrides) + } + + /** + * Delete a workspace. + * @param workspaceId The ID or domain address of the workspace to delete + * @param overrides Optional request overrides to apply + * @return The deletion response + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun destroy(workspaceId: String, overrides: RequestOverrides? = null): DeleteResponse { + val path = String.format("v3/workspaces/%s", PathEncoder.encode(workspaceId)) + return destroyResource(path, overrides = overrides) + } + + /** + * Start a background job that auto-groups grants into workspaces by email domain. + * @param requestBody Optional filters to scope which grants are grouped + * @param overrides Optional request overrides to apply + * @return The auto-group job response + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun autoGroup( + requestBody: WorkspaceAutoGroupRequest? = null, + overrides: RequestOverrides? = null, + ): Response { + val responseType = Types.newParameterizedType(Response::class.java, WorkspaceAutoGroupResponse::class.java) + val serializedRequestBody = requestBody?.let { + JsonHelper.moshi().adapter(WorkspaceAutoGroupRequest::class.java).toJson(it) + } + return client.executePost("v3/workspaces/auto-group", responseType, serializedRequestBody, overrides = overrides) + } + + /** + * Manually assign grants to or remove grants from a workspace. + * @param workspaceId The ID or domain address of the workspace + * @param requestBody The grants to assign and/or remove + * @param overrides Optional request overrides to apply + * @return The assignment response + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun manualAssign( + workspaceId: String, + requestBody: WorkspaceManualAssignRequest, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/workspaces/%s/manual-assign", PathEncoder.encode(workspaceId)) + val responseType = Types.newParameterizedType(Response::class.java, WorkspaceManualAssignResponse::class.java) + val serializedRequestBody = JsonHelper.moshi().adapter(WorkspaceManualAssignRequest::class.java).toJson(requestBody) + return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + } +} diff --git a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt index 3c19353f..c8648d30 100644 --- a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt @@ -148,6 +148,33 @@ class ApplicationsTests { assertEquals("v3/applications", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, ApplicationDetails::class.java), typeCaptor.firstValue) } + + @Test + fun `updating application details calls requests with the correct params`() { + val requestBody = UpdateApplicationRequest( + branding = ApplicationDetails.Branding(name = "Renamed app"), + ) + val adapter = JsonHelper.moshi().adapter(UpdateApplicationRequest::class.java) + + mockApplications.update(requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePatch>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/applications", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, ApplicationDetails::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } } @Nested diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index 5b1ef036..7293b271 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -270,5 +270,160 @@ class DomainsTests { ) assertEquals("override-key", overrideParamCaptor.firstValue.apiKey) } + + @Test + fun `listing managed domains calls requests with the correct params`() { + val queryParams = ListDomainsQueryParams(limit = 5, pageToken = "cursor123") + domains.list(queryParams) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeGet>( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(ListResponse::class.java, Domain::class.java), typeCaptor.firstValue) + assertEquals(queryParams, queryParamCaptor.firstValue) + } + + @Test + fun `finding a managed domain calls requests with the correct params`() { + domains.find("domain/with/slash") + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeGet>( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains/domain%2Fwith%2Fslash", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) + } + + @Test + fun `creating a managed domain calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(CreateDomainRequest::class.java) + val requestBody = CreateDomainRequest(name = "Acme", domainAddress = "mail.acme.com") + domains.create(requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + + @Test + fun `updating a managed domain calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java) + val requestBody = UpdateDomainRequest(name = "Renamed") + domains.update("dom-123", requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePut>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + + @Test + fun `destroying a managed domain calls requests with the correct params`() { + domains.destroy("dom-123") + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeDelete( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) + assertEquals(DeleteResponse::class.java, typeCaptor.firstValue) + } + + @Test + fun `getting managed domain info calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) + val requestBody = DomainVerificationRequest(DomainVerificationType.SPF) + domains.info("dom-123", requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains/dom-123/info", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + + @Test + fun `verifying a managed domain calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) + val requestBody = DomainVerificationRequest(DomainVerificationType.DKIM) + domains.verify("dom-123", requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/admin/domains/dom-123/verify", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } } } diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index fa5fafd2..51c6f3cc 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -18,6 +18,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.kotlin.whenever import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals @@ -244,6 +245,76 @@ class RulesTests { assertNull(rule.match) assertNull(rule.description) } + + @Test + fun `RulesListResponse unwraps nested rules list response`() { + val adapter = JsonHelper.moshi().adapter(RulesListResponse::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "request_id": "req-123", + "data": { + "items": [ + { + "id": "rule-1", + "name": "Block spam", + "actions": [], + "application_id": "app-id", + "organization_id": "org-id", + "created_at": 1000, + "updated_at": 1000 + } + ], + "next_cursor": "cursor-123" + } + } + """.trimIndent(), + ) + + val response = adapter.fromJson(jsonBuffer)!!.toListResponse() + + assertEquals("req-123", response.requestId) + assertEquals("cursor-123", response.nextCursor) + assertEquals("rule-1", response.data.first().id) + } + + @Test + fun `RuleEvaluation deserializes properly`() { + val adapter = JsonHelper.moshi().adapter(RuleEvaluation::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "id": "eval-123", + "grant_id": "grant-123", + "message_id": "message-123", + "evaluated_at": 1742933005, + "evaluation_stage": "inbox_processing", + "evaluation_input": { + "from_address": "sender@example.com", + "from_domain": "example.com", + "from_tld": "com" + }, + "applied_actions": { + "marked_as_spam": true, + "folder_ids": ["spam-folder"] + }, + "matched_rule_ids": ["rule-1"], + "application_id": "app-id", + "organization_id": "org-id", + "created_at": 1742933005, + "updated_at": 1742933005 + } + """.trimIndent(), + ) + + val evaluation = adapter.fromJson(jsonBuffer)!! + + assertEquals("eval-123", evaluation.id) + assertEquals(RuleEvaluationStage.INBOX_PROCESSING, evaluation.evaluationStage) + assertEquals("example.com", evaluation.evaluationInput?.fromDomain) + assertEquals(true, evaluation.appliedActions?.markedAsSpam) + assertEquals(listOf("rule-1"), evaluation.matchedRuleIds) + } } @Nested @@ -259,13 +330,15 @@ class RulesTests { @Test fun `listing rules calls requests with the correct params`() { + whenever(mockNylasClient.executeGet(any(), any(), any(), any())).thenReturn(RulesListResponse()) + rules.list() val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() val queryParamCaptor = argumentCaptor() val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet>( + verify(mockNylasClient).executeGet( pathCaptor.capture(), typeCaptor.capture(), queryParamCaptor.capture(), @@ -273,20 +346,22 @@ class RulesTests { ) assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(Types.newParameterizedType(ListResponse::class.java, Rule::class.java), typeCaptor.firstValue) + assertEquals(RulesListResponse::class.java, typeCaptor.firstValue) assertNull(queryParamCaptor.firstValue) } @Test fun `listing rules with query params passes them correctly`() { val queryParams = ListRulesQueryParams(limit = 5, pageToken = "cursor123") + whenever(mockNylasClient.executeGet(any(), any(), any(), any())).thenReturn(RulesListResponse()) + rules.list(queryParams) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() val queryParamCaptor = argumentCaptor() val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet>( + verify(mockNylasClient).executeGet( pathCaptor.capture(), typeCaptor.capture(), queryParamCaptor.capture(), @@ -395,5 +470,26 @@ class RulesTests { assertEquals(DeleteResponse::class.java, typeCaptor.firstValue) assertNull(queryParamCaptor.firstValue) } + + @Test + fun `listing rule evaluations calls requests with the correct params`() { + val queryParams = ListRuleEvaluationsQueryParams(limit = 5, pageToken = "cursor123") + rules.listEvaluations("grant-abc123", queryParams) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeGet>( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/grants/grant-abc123/rule-evaluations", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(ListResponse::class.java, RuleEvaluation::class.java), typeCaptor.firstValue) + assertEquals(queryParams, queryParamCaptor.firstValue) + } } } diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt new file mode 100644 index 00000000..8f8a1837 --- /dev/null +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -0,0 +1,237 @@ +package com.nylas.resources + +import com.nylas.NylasClient +import com.nylas.models.* +import com.nylas.models.Response +import com.nylas.util.JsonHelper +import com.squareup.moshi.Types +import okio.Buffer +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.mockito.Mockito +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import java.lang.reflect.Type +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class WorkspacesTests { + @Nested + inner class SerializationTests { + @Test + fun `Workspace deserializes properly`() { + val adapter = JsonHelper.moshi().adapter(Workspace::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "workspace_id": "ws-123", + "application_id": "app-123", + "name": "Acme", + "domain": "acme.com", + "auto_group": true, + "policy_id": "policy-123", + "rules_ids": ["rule-1"], + "created_at": 1742933005, + "updated_at": 1742933006 + } + """.trimIndent(), + ) + + val workspace = adapter.fromJson(jsonBuffer)!! + + assertIs(workspace) + assertEquals("ws-123", workspace.workspaceId) + assertEquals("acme.com", workspace.domain) + assertEquals(true, workspace.autoGroup) + assertEquals(listOf("rule-1"), workspace.rulesIds) + } + + @Test + fun `WorkspaceManualAssignResponse allows null grant arrays`() { + val adapter = JsonHelper.moshi().adapter(WorkspaceManualAssignResponse::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "application_id": "app-123", + "workspace_id": "ws-123", + "domain": "acme.com", + "grants_assigned": null, + "grants_removed": null + } + """.trimIndent(), + ) + + val response = adapter.fromJson(jsonBuffer)!! + + assertNull(response.grantsAssigned) + assertNull(response.grantsRemoved) + } + } + + @Nested + inner class CrudTests { + private lateinit var mockNylasClient: NylasClient + private lateinit var workspaces: Workspaces + + @BeforeEach + fun setup() { + mockNylasClient = Mockito.mock(NylasClient::class.java) + workspaces = Workspaces(mockNylasClient) + } + + @Test + fun `listing workspaces calls requests with the correct params`() { + workspaces.list() + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeGet>( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(ListResponse::class.java, Workspace::class.java), typeCaptor.firstValue) + } + + @Test + fun `finding a workspace calls requests with the correct params`() { + workspaces.find("domain/with/slash") + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeGet>( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces/domain%2Fwith%2Fslash", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Workspace::class.java), typeCaptor.firstValue) + } + + @Test + fun `creating a workspace calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(CreateWorkspaceRequest::class.java) + val requestBody = CreateWorkspaceRequest.Builder("Acme").domain("acme.com").autoGroup(true).build() + workspaces.create(requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Workspace::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + + @Test + fun `updating a workspace uses patch with the correct params`() { + val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) + val requestBody = UpdateWorkspaceRequest.Builder().name("Renamed").build() + workspaces.update("ws-123", requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePatch>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces/ws-123", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Workspace::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + + @Test + fun `destroying a workspace calls requests with the correct params`() { + workspaces.destroy("ws-123") + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeDelete( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces/ws-123", pathCaptor.firstValue) + assertEquals(DeleteResponse::class.java, typeCaptor.firstValue) + } + + @Test + fun `auto-grouping workspaces calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(WorkspaceAutoGroupRequest::class.java) + val requestBody = WorkspaceAutoGroupRequest(afterCreatedAt = 1742933005, specificDomain = "acme.com") + workspaces.autoGroup(requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces/auto-group", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, WorkspaceAutoGroupResponse::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + + @Test + fun `manually assigning a workspace calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(WorkspaceManualAssignRequest::class.java) + val requestBody = WorkspaceManualAssignRequest(assignGrants = listOf("grant-1"), removeGrants = listOf("grant-2")) + workspaces.manualAssign("ws-123", requestBody) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces/ws-123/manual-assign", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, WorkspaceManualAssignResponse::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + } + } +} From ff681170e60897586ab1566daaca2ac2927f63de Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:30:59 -0400 Subject: [PATCH 02/23] TW-5374 Fix CI lint --- CHANGELOG.md | 10 +++++----- src/test/kotlin/com/nylas/resources/RulesTests.kt | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d9401e..4dd6b195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] ### Added +* Application administration updates + - `Applications.update()` for `PATCH /v3/applications` + - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` + - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains` + - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, and `manualAssign` * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` - `NylasClient.domains()` accessor returning the new `Domains` resource @@ -12,11 +17,6 @@ - `Policies` resource via `client.policies()`: full CRUD (`list`, `find`, `create`, `update`, `destroy`) with `CreatePolicyRequest` / `UpdatePolicyRequest` and supporting models (`Policy`, `PolicyLimits`, `PolicyOptions`, `PolicySpamDetection`) - `Rules` resource via `client.rules()`: full CRUD plus `listEvaluations` for grant rule-evaluation audit records; handles the nested `/v3/rules` list envelope returned by the API - `NylasLists` resource via `client.lists()`: full CRUD plus `listItems`, `addItems`, and `removeItems` for managing list contents; `NylasList`, `NylasListItem`, `NylasListType`, `ListItemsRequest` models -* Application administration updates - - `Applications.update()` for `PATCH /v3/applications` - - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains` - - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, and `manualAssign` ## [v2.16.1] - Release 2026-05-21 diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index 51c6f3cc..59ba1fce 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -18,7 +18,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.mockito.kotlin.whenever import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals From 026beb2f91391cf733d9fc46523e208d8abdc999 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:38:22 -0400 Subject: [PATCH 03/23] TW-5374 Align workspace schema with source --- CHANGELOG.md | 2 +- .../kotlin/com/nylas/models/CreateWorkspaceRequest.kt | 10 +++++----- .../kotlin/com/nylas/models/UpdateWorkspaceRequest.kt | 10 +++++----- src/main/kotlin/com/nylas/models/Workspace.kt | 6 ++++-- src/test/kotlin/com/nylas/resources/WorkspacesTests.kt | 6 ++++-- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd6b195..bb6c07b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `Applications.update()` for `PATCH /v3/applications` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains` - - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, and `manualAssign` + - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds` * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` - `NylasClient.domains()` accessor returning the new `Domains` resource diff --git a/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt index 38a182c1..db83f7f5 100644 --- a/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt @@ -14,19 +14,19 @@ data class CreateWorkspaceRequest( val autoGroup: Boolean? = null, @Json(name = "policy_id") val policyId: String? = null, - @Json(name = "rules_ids") - val rulesIds: List? = null, + @Json(name = "rule_ids") + val ruleIds: List? = null, ) { data class Builder(private val name: String) { private var domain: String? = null private var autoGroup: Boolean? = null private var policyId: String? = null - private var rulesIds: List? = null + private var ruleIds: List? = null fun domain(domain: String?) = apply { this.domain = domain } fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } fun policyId(policyId: String?) = apply { this.policyId = policyId } - fun rulesIds(rulesIds: List?) = apply { this.rulesIds = rulesIds } - fun build() = CreateWorkspaceRequest(name, domain, autoGroup, policyId, rulesIds) + fun ruleIds(ruleIds: List?) = apply { this.ruleIds = ruleIds } + fun build() = CreateWorkspaceRequest(name, domain, autoGroup, policyId, ruleIds) } } diff --git a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt index d5785293..3517a37b 100644 --- a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt @@ -14,21 +14,21 @@ data class UpdateWorkspaceRequest( val autoGroup: Boolean? = null, @Json(name = "policy_id") val policyId: String? = null, - @Json(name = "rules_ids") - val rulesIds: List? = null, + @Json(name = "rule_ids") + val ruleIds: List? = null, ) { class Builder { private var name: String? = null private var domain: String? = null private var autoGroup: Boolean? = null private var policyId: String? = null - private var rulesIds: List? = null + private var ruleIds: List? = null fun name(name: String?) = apply { this.name = name } fun domain(domain: String?) = apply { this.domain = domain } fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } fun policyId(policyId: String?) = apply { this.policyId = policyId } - fun rulesIds(rulesIds: List?) = apply { this.rulesIds = rulesIds } - fun build() = UpdateWorkspaceRequest(name, domain, autoGroup, policyId, rulesIds) + fun ruleIds(ruleIds: List?) = apply { this.ruleIds = ruleIds } + fun build() = UpdateWorkspaceRequest(name, domain, autoGroup, policyId, ruleIds) } } diff --git a/src/main/kotlin/com/nylas/models/Workspace.kt b/src/main/kotlin/com/nylas/models/Workspace.kt index a0cc8086..34088b8a 100644 --- a/src/main/kotlin/com/nylas/models/Workspace.kt +++ b/src/main/kotlin/com/nylas/models/Workspace.kt @@ -16,10 +16,12 @@ data class Workspace( val domain: String, @Json(name = "auto_group") val autoGroup: Boolean, + @Json(name = "default") + val default: Boolean? = null, @Json(name = "policy_id") val policyId: String? = null, - @Json(name = "rules_ids") - val rulesIds: List? = null, + @Json(name = "rule_ids") + val ruleIds: List? = null, @Json(name = "created_at") val createdAt: Long, @Json(name = "updated_at") diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt index 8f8a1837..e7f0721f 100644 --- a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -31,8 +31,9 @@ class WorkspacesTests { "name": "Acme", "domain": "acme.com", "auto_group": true, + "default": true, "policy_id": "policy-123", - "rules_ids": ["rule-1"], + "rule_ids": ["rule-1"], "created_at": 1742933005, "updated_at": 1742933006 } @@ -45,7 +46,8 @@ class WorkspacesTests { assertEquals("ws-123", workspace.workspaceId) assertEquals("acme.com", workspace.domain) assertEquals(true, workspace.autoGroup) - assertEquals(listOf("rule-1"), workspace.rulesIds) + assertEquals(true, workspace.default) + assertEquals(listOf("rule-1"), workspace.ruleIds) } @Test From f9bafaa709ab9569fd3c08088dc6257cbcb28cfc Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:42:17 -0400 Subject: [PATCH 04/23] TW-5374 Fix rules list test stubs --- src/test/kotlin/com/nylas/resources/RulesTests.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index 59ba1fce..d8c9e255 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Nested import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -329,7 +330,9 @@ class RulesTests { @Test fun `listing rules calls requests with the correct params`() { - whenever(mockNylasClient.executeGet(any(), any(), any(), any())).thenReturn(RulesListResponse()) + whenever( + mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), + ).thenReturn(RulesListResponse()) rules.list() @@ -352,7 +355,9 @@ class RulesTests { @Test fun `listing rules with query params passes them correctly`() { val queryParams = ListRulesQueryParams(limit = 5, pageToken = "cursor123") - whenever(mockNylasClient.executeGet(any(), any(), any(), any())).thenReturn(RulesListResponse()) + whenever( + mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), + ).thenReturn(RulesListResponse()) rules.list(queryParams) From 071c6dd1309cd899094ef4fbbf0418f4d8fd8877 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 22:38:45 -0400 Subject: [PATCH 05/23] TW-5374 Validate workspace auto-group domain --- CHANGELOG.md | 2 +- .../com/nylas/models/CreateWorkspaceRequest.kt | 6 ++++++ .../com/nylas/resources/WorkspacesTests.kt | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6c07b7..1f14529b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `Applications.update()` for `PATCH /v3/applications` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains` - - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds` + - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` - `NylasClient.domains()` accessor returning the new `Domains` resource diff --git a/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt index db83f7f5..f73e58a8 100644 --- a/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt @@ -17,6 +17,12 @@ data class CreateWorkspaceRequest( @Json(name = "rule_ids") val ruleIds: List? = null, ) { + init { + require(autoGroup != true || !domain.isNullOrBlank()) { + "domain is required when autoGroup is true" + } + } + data class Builder(private val name: String) { private var domain: String? = null private var autoGroup: Boolean? = null diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt index e7f0721f..6e5cf743 100644 --- a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -14,6 +14,7 @@ import org.mockito.kotlin.verify import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -70,6 +71,21 @@ class WorkspacesTests { assertNull(response.grantsAssigned) assertNull(response.grantsRemoved) } + + @Test + fun `CreateWorkspaceRequest requires domain only when auto-group is true`() { + assertFailsWith { + CreateWorkspaceRequest.Builder("Acme").autoGroup(true).build() + } + + val explicitNoAutoGroup = CreateWorkspaceRequest.Builder("Acme").autoGroup(false).build() + assertNull(explicitNoAutoGroup.domain) + assertEquals(false, explicitNoAutoGroup.autoGroup) + + val defaultAutoGroup = CreateWorkspaceRequest.Builder("Acme").build() + assertNull(defaultAutoGroup.domain) + assertNull(defaultAutoGroup.autoGroup) + } } @Nested From 607da366042f95dc27c9c2fa406ad9c941b28f87 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:06:51 -0400 Subject: [PATCH 06/23] Add lists admin API support --- CHANGELOG.md | 3 +- .../nylas/models/CreateNylasListRequest.kt | 6 + src/main/kotlin/com/nylas/models/Domain.kt | 6 - .../nylas/models/ListDomainsQueryParams.kt | 10 +- .../com/nylas/models/UpdateDomainRequest.kt | 22 +--- .../nylas/models/UpdateWorkspaceRequest.kt | 15 ++- .../kotlin/com/nylas/resources/Domains.kt | 88 +++++++++----- .../com/nylas/resources/DomainsTests.kt | 112 ++++++++++++++++-- .../com/nylas/resources/NylasListsTests.kt | 35 +++++- .../com/nylas/resources/WorkspacesTests.kt | 55 +++++++++ 10 files changed, 270 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f14529b..231679ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * Application administration updates - `Applications.update()` for `PATCH /v3/applications` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains` + - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these require Nylas Service Account request-signing headers in `RequestOverrides.headers` - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` @@ -17,6 +17,7 @@ - `Policies` resource via `client.policies()`: full CRUD (`list`, `find`, `create`, `update`, `destroy`) with `CreatePolicyRequest` / `UpdatePolicyRequest` and supporting models (`Policy`, `PolicyLimits`, `PolicyOptions`, `PolicySpamDetection`) - `Rules` resource via `client.rules()`: full CRUD plus `listEvaluations` for grant rule-evaluation audit records; handles the nested `/v3/rules` list envelope returned by the API - `NylasLists` resource via `client.lists()`: full CRUD plus `listItems`, `addItems`, and `removeItems` for managing list contents; `NylasList`, `NylasListItem`, `NylasListType`, `ListItemsRequest` models + - `NylasLists.create()` for `POST /v3/lists` with `CreateNylasListRequest` (`name`, `type`, and optional `description`) ## [v2.16.1] - Release 2026-05-21 diff --git a/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt b/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt index 6372e7f4..7fab5454 100644 --- a/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt @@ -22,6 +22,12 @@ data class CreateNylasListRequest( @Json(name = "description") val description: String? = null, ) { + init { + require(name.isNotBlank() && name.length <= 256) { + "name must be between 1 and 256 characters" + } + } + /** * Builder for [CreateNylasListRequest]. * @param name Name of the list. diff --git a/src/main/kotlin/com/nylas/models/Domain.kt b/src/main/kotlin/com/nylas/models/Domain.kt index 3451823c..9b9e0362 100644 --- a/src/main/kotlin/com/nylas/models/Domain.kt +++ b/src/main/kotlin/com/nylas/models/Domain.kt @@ -18,12 +18,6 @@ data class Domain( val branded: Boolean? = null, @Json(name = "region") val region: String? = null, - @Json(name = "tenant_key") - val tenantKey: String? = null, - @Json(name = "dkim_public_key") - val dkimPublicKey: String? = null, - @Json(name = "dkim_submitted_at") - val dkimSubmittedAt: Long? = null, @Json(name = "verified_ownership") val verifiedOwnership: Boolean? = null, @Json(name = "verified_mx") diff --git a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt index a4fe0d9b..43ee4a80 100644 --- a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt @@ -6,25 +6,17 @@ import com.squareup.moshi.Json * Class representation of the query parameters for listing domains. */ data class ListDomainsQueryParams( - @Json(name = "domain") - val domain: String? = null, - @Json(name = "region") - val region: String? = null, @Json(name = "limit") val limit: Int? = null, @Json(name = "page_token") val pageToken: String? = null, ) : IQueryParams { class Builder { - private var domain: String? = null - private var region: String? = null private var limit: Int? = null private var pageToken: String? = null - fun domain(domain: String?) = apply { this.domain = domain } - fun region(region: String?) = apply { this.region = region } fun limit(limit: Int?) = apply { this.limit = limit } fun pageToken(pageToken: String?) = apply { this.pageToken = pageToken } - fun build() = ListDomainsQueryParams(domain, region, limit, pageToken) + fun build() = ListDomainsQueryParams(limit, pageToken) } } diff --git a/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt b/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt index d591df1e..f7e9ad14 100644 --- a/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt @@ -8,35 +8,15 @@ import com.squareup.moshi.Json data class UpdateDomainRequest( @Json(name = "name") val name: String? = null, - @Json(name = "region") - val region: String? = null, - @Json(name = "tenant_key") - val tenantKey: String? = null, - @Json(name = "dkim_public_key") - val dkimPublicKey: String? = null, - @Json(name = "dkim_submitted_at") - val dkimSubmittedAt: Long? = null, - @Json(name = "verified_feedback") - val verifiedFeedback: Boolean? = null, ) { /** * Builder for [UpdateDomainRequest]. */ class Builder { private var name: String? = null - private var region: String? = null - private var tenantKey: String? = null - private var dkimPublicKey: String? = null - private var dkimSubmittedAt: Long? = null - private var verifiedFeedback: Boolean? = null fun name(name: String?) = apply { this.name = name } - fun region(region: String?) = apply { this.region = region } - fun tenantKey(tenantKey: String?) = apply { this.tenantKey = tenantKey } - fun dkimPublicKey(dkimPublicKey: String?) = apply { this.dkimPublicKey = dkimPublicKey } - fun dkimSubmittedAt(dkimSubmittedAt: Long?) = apply { this.dkimSubmittedAt = dkimSubmittedAt } - fun verifiedFeedback(verifiedFeedback: Boolean?) = apply { this.verifiedFeedback = verifiedFeedback } - fun build() = UpdateDomainRequest(name, region, tenantKey, dkimPublicKey, dkimSubmittedAt, verifiedFeedback) + fun build() = UpdateDomainRequest(name) } } diff --git a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt index 3517a37b..c426e381 100644 --- a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt @@ -8,27 +8,26 @@ import com.squareup.moshi.Json data class UpdateWorkspaceRequest( @Json(name = "name") val name: String? = null, - @Json(name = "domain") - val domain: String? = null, @Json(name = "auto_group") val autoGroup: Boolean? = null, @Json(name = "policy_id") - val policyId: String? = null, + val policyId: NullableField? = null, @Json(name = "rule_ids") val ruleIds: List? = null, ) { class Builder { private var name: String? = null - private var domain: String? = null private var autoGroup: Boolean? = null - private var policyId: String? = null + private var policyId: NullableField? = null private var ruleIds: List? = null fun name(name: String?) = apply { this.name = name } - fun domain(domain: String?) = apply { this.domain = domain } fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } - fun policyId(policyId: String?) = apply { this.policyId = policyId } + fun policyId(policyId: String?) = apply { + this.policyId = if (policyId == null) NullableField.Clear else NullableField.Value(policyId) + } + fun clearPolicyId() = apply { this.policyId = NullableField.Clear } fun ruleIds(ruleIds: List?) = apply { this.ruleIds = ruleIds } - fun build() = UpdateWorkspaceRequest(name, domain, autoGroup, policyId, ruleIds) + fun build() = UpdateWorkspaceRequest(name, autoGroup, policyId, ruleIds) } } diff --git a/src/main/kotlin/com/nylas/resources/Domains.kt b/src/main/kotlin/com/nylas/resources/Domains.kt index 839a6489..c273e27a 100644 --- a/src/main/kotlin/com/nylas/resources/Domains.kt +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -8,6 +8,19 @@ import com.nylas.util.PathEncoder import com.squareup.moshi.Types class Domains(client: NylasClient) : Resource(client, Message::class.java) { + private fun requireServiceAccountSigning(overrides: RequestOverrides): RequestOverrides { + val headers = overrides.headers.orEmpty() + val normalizedHeaders = headers.entries.associate { it.key.lowercase() to it.value } + val missingHeaders = SERVICE_ACCOUNT_SIGNING_HEADERS.filter { + normalizedHeaders[it.lowercase()].isNullOrBlank() + } + + require(missingHeaders.isEmpty()) { + "Manage Domains endpoints require Nylas Service Account signing headers: ${missingHeaders.joinToString()}" + } + + return overrides + } /** * Send a transactional email from a verified domain. @@ -39,114 +52,133 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja } } + /** + * Return all managed domains for the caller's organization. + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The list of managed domains + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + fun list(overrides: RequestOverrides): ListResponse { + return list(null, overrides) + } + /** * Return all managed domains for the caller's organization. * @param queryParams Optional query parameters to apply - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The list of managed domains */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads - fun list(queryParams: ListDomainsQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { + fun list(queryParams: ListDomainsQueryParams? = null, overrides: RequestOverrides): ListResponse { + val signedOverrides = requireServiceAccountSigning(overrides) val responseType = Types.newParameterizedType(ListResponse::class.java, Domain::class.java) - return client.executeGet("v3/admin/domains", responseType, queryParams, overrides) + return client.executeGet("v3/admin/domains", responseType, queryParams, signedOverrides) } /** * Return a managed domain. * @param domainId The ID or domain address of the domain to retrieve - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The managed domain */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads - fun find(domainId: String, overrides: RequestOverrides? = null): Response { + fun find(domainId: String, overrides: RequestOverrides): Response { + val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) - return client.executeGet(path, responseType, overrides = overrides) + return client.executeGet(path, responseType, overrides = signedOverrides) } /** * Create a managed domain. * @param requestBody The values to create the domain with - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The created managed domain */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads - fun create(requestBody: CreateDomainRequest, overrides: RequestOverrides? = null): Response { + fun create(requestBody: CreateDomainRequest, overrides: RequestOverrides): Response { + val signedOverrides = requireServiceAccountSigning(overrides) val path = "v3/admin/domains" val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) val serializedRequestBody = JsonHelper.moshi().adapter(CreateDomainRequest::class.java).toJson(requestBody) - return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } /** * Update a managed domain. * @param domainId The ID or domain address of the domain to update * @param requestBody The values to update the domain with - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The updated managed domain fields */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads - fun update(domainId: String, requestBody: UpdateDomainRequest, overrides: RequestOverrides? = null): Response { + fun update(domainId: String, requestBody: UpdateDomainRequest, overrides: RequestOverrides): Response { + val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) val serializedRequestBody = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java).toJson(requestBody) - return client.executePut(path, responseType, serializedRequestBody, overrides = overrides) + return client.executePut(path, responseType, serializedRequestBody, overrides = signedOverrides) } /** * Delete a managed domain. * @param domainId The ID or domain address of the domain to delete - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The deletion response */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads - fun destroy(domainId: String, overrides: RequestOverrides? = null): DeleteResponse { + fun destroy(domainId: String, overrides: RequestOverrides): DeleteResponse { + val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) - return client.executeDelete(path, DeleteResponse::class.java, overrides = overrides) + return client.executeDelete(path, DeleteResponse::class.java, overrides = signedOverrides) } /** * Get DNS record information for a domain verification type. * @param domainId The ID or domain address of the domain * @param requestBody The verification type to inspect - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The domain verification result */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads fun info( domainId: String, requestBody: DomainVerificationRequest, - overrides: RequestOverrides? = null, + overrides: RequestOverrides, ): Response { + val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s/info", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) val serializedRequestBody = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java).toJson(requestBody) - return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } /** * Trigger a DNS verification check for a domain verification type. * @param domainId The ID or domain address of the domain * @param requestBody The verification type to verify - * @param overrides Optional request overrides to apply + * @param overrides Request overrides containing Nylas Service Account signing headers * @return The domain verification result */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) - @JvmOverloads fun verify( domainId: String, requestBody: DomainVerificationRequest, - overrides: RequestOverrides? = null, + overrides: RequestOverrides, ): Response { + val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s/verify", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) val serializedRequestBody = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java).toJson(requestBody) - return client.executePost(path, responseType, serializedRequestBody, overrides = overrides) + return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) + } + + companion object { + private val SERVICE_ACCOUNT_SIGNING_HEADERS = listOf( + "X-Nylas-Kid", + "X-Nylas-Timestamp", + "X-Nylas-Nonce", + "X-Nylas-Signature", + ) } } diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index 7293b271..8e20e4af 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -116,6 +117,69 @@ class DomainsTests { assert(!json.contains("\"send_at\"")) { "Expected no send_at field, got: $json" } assert(!json.contains("\"is_plaintext\"")) { "Expected no is_plaintext field, got: $json" } } + + @Test + fun `Domain ignores unexpected response fields`() { + val adapter = JsonHelper.moshi().adapter(Domain::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "id": "dom-123", + "name": "Acme", + "domain_address": "mail.acme.com", + "organization_id": "org-123", + "region": "us", + "unexpected_response_id": "unexpected-value", + "unexpected_status": "internal-value", + "unexpected_timestamp": 1742933005, + "verified_ownership": true, + "verified_dkim": true, + "verified_spf": true, + "verified_mx": true, + "verified_feedback": false, + "verified_dmarc": false, + "verified_arc": false, + "created_at": 1742932766, + "updated_at": 1742933005 + } + """.trimIndent(), + ) + + val domain = adapter.fromJson(jsonBuffer)!! + val json = adapter.toJson(domain) + + assertEquals("dom-123", domain.id) + assertEquals("mail.acme.com", domain.domainAddress) + assert(!json.contains("unexpected_response_id")) { + "Expected unexpected_response_id to stay out of public serialization, got: $json" + } + assert(!json.contains("unexpected_status")) { + "Expected unexpected_status to stay out of public serialization, got: $json" + } + assert(!json.contains("unexpected_timestamp")) { + "Expected unexpected_timestamp to stay out of public serialization, got: $json" + } + } + + @Test + fun `UpdateDomainRequest only serializes documented update fields`() { + val adapter = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java) + val request = UpdateDomainRequest.Builder().name("Renamed").build() + + val json = adapter.toJson(request) + + assertEquals("""{"name":"Renamed"}""", json) + } + + @Test + fun `ListDomainsQueryParams only exposes documented query parameters`() { + val queryParams = ListDomainsQueryParams.Builder() + .limit(5) + .pageToken("cursor123") + .build() + + assertEquals(mapOf("limit" to 5.0, "page_token" to "cursor123"), queryParams.convertToMap()) + } } @Nested @@ -124,6 +188,15 @@ class DomainsTests { private lateinit var mockNylasClient: NylasClient private lateinit var domains: Domains + private fun serviceAccountOverrides() = RequestOverrides( + headers = mapOf( + "X-Nylas-Kid" to "service-account-id", + "X-Nylas-Timestamp" to "1742932766", + "X-Nylas-Nonce" to "nonce", + "X-Nylas-Signature" to "signature", + ), + ) + @BeforeEach fun setup() { domainName = "acme.com" @@ -274,7 +347,8 @@ class DomainsTests { @Test fun `listing managed domains calls requests with the correct params`() { val queryParams = ListDomainsQueryParams(limit = 5, pageToken = "cursor123") - domains.list(queryParams) + val overrides = serviceAccountOverrides() + domains.list(queryParams, overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -290,11 +364,24 @@ class DomainsTests { assertEquals("v3/admin/domains", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(ListResponse::class.java, Domain::class.java), typeCaptor.firstValue) assertEquals(queryParams, queryParamCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) + } + + @Test + fun `managed domain endpoints require service account signing headers`() { + val exception = assertFailsWith { + domains.list(ListDomainsQueryParams(limit = 5), RequestOverrides(headers = mapOf("X-Nylas-Kid" to ""))) + } + + assert(exception.message!!.contains("X-Nylas-Timestamp")) { + "Expected missing signing headers in error, got: ${exception.message}" + } } @Test fun `finding a managed domain calls requests with the correct params`() { - domains.find("domain/with/slash") + val overrides = serviceAccountOverrides() + domains.find("domain/with/slash", overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -309,13 +396,15 @@ class DomainsTests { assertEquals("v3/admin/domains/domain%2Fwith%2Fslash", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) } @Test fun `creating a managed domain calls requests with the correct params`() { val adapter = JsonHelper.moshi().adapter(CreateDomainRequest::class.java) val requestBody = CreateDomainRequest(name = "Acme", domainAddress = "mail.acme.com") - domains.create(requestBody) + val overrides = serviceAccountOverrides() + domains.create(requestBody, overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -333,13 +422,15 @@ class DomainsTests { assertEquals("v3/admin/domains", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) } @Test fun `updating a managed domain calls requests with the correct params`() { val adapter = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java) val requestBody = UpdateDomainRequest(name = "Renamed") - domains.update("dom-123", requestBody) + val overrides = serviceAccountOverrides() + domains.update("dom-123", requestBody, overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -357,11 +448,13 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) } @Test fun `destroying a managed domain calls requests with the correct params`() { - domains.destroy("dom-123") + val overrides = serviceAccountOverrides() + domains.destroy("dom-123", overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -376,13 +469,15 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) assertEquals(DeleteResponse::class.java, typeCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) } @Test fun `getting managed domain info calls requests with the correct params`() { val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) val requestBody = DomainVerificationRequest(DomainVerificationType.SPF) - domains.info("dom-123", requestBody) + val overrides = serviceAccountOverrides() + domains.info("dom-123", requestBody, overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -400,13 +495,15 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123/info", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) } @Test fun `verifying a managed domain calls requests with the correct params`() { val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) val requestBody = DomainVerificationRequest(DomainVerificationType.DKIM) - domains.verify("dom-123", requestBody) + val overrides = serviceAccountOverrides() + domains.verify("dom-123", requestBody, overrides) val pathCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -424,6 +521,7 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123/verify", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals(overrides, overrideParamCaptor.firstValue) } } } diff --git a/src/test/kotlin/com/nylas/resources/NylasListsTests.kt b/src/test/kotlin/com/nylas/resources/NylasListsTests.kt index 9fb64782..92325d16 100644 --- a/src/test/kotlin/com/nylas/resources/NylasListsTests.kt +++ b/src/test/kotlin/com/nylas/resources/NylasListsTests.kt @@ -21,6 +21,7 @@ import org.mockito.kotlin.whenever import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -101,12 +102,28 @@ class NylasListsTests { @Test fun `CreateNylasListRequest serializes correctly`() { val adapter = JsonHelper.moshi().adapter(CreateNylasListRequest::class.java) - val request = CreateNylasListRequest(name = "Blocked domains", type = NylasListType.DOMAIN) + val request = CreateNylasListRequest( + name = "Blocked domains", + type = NylasListType.DOMAIN, + description = "Domains we have identified as sending unwanted mail.", + ) val json = adapter.toJson(request) val deserialized = adapter.fromJson(json)!! + assertEquals( + """{"name":"Blocked domains","type":"domain","description":"Domains we have identified as sending unwanted mail."}""", + json, + ) assertEquals("Blocked domains", deserialized.name) assertEquals(NylasListType.DOMAIN, deserialized.type) - assertNull(deserialized.description) + assertEquals("Domains we have identified as sending unwanted mail.", deserialized.description) + } + + @Test + fun `CreateNylasListRequest omits null description`() { + val adapter = JsonHelper.moshi().adapter(CreateNylasListRequest::class.java) + val request = CreateNylasListRequest(name = "Blocked domains", type = NylasListType.DOMAIN) + + assertEquals("""{"name":"Blocked domains","type":"domain"}""", adapter.toJson(request)) } @Test @@ -120,6 +137,20 @@ class NylasListsTests { assertEquals("Top-level domains to block", request.description) } + @Test + fun `CreateNylasListRequest rejects blank name`() { + assertFailsWith { + CreateNylasListRequest(name = " ", type = NylasListType.DOMAIN) + } + } + + @Test + fun `CreateNylasListRequest rejects names over 256 characters`() { + assertFailsWith { + CreateNylasListRequest(name = "a".repeat(257), type = NylasListType.DOMAIN) + } + } + @Test fun `ListItemsRequest serializes correctly`() { val adapter = JsonHelper.moshi().adapter(ListItemsRequest::class.java) diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt index 6e5cf743..e263708b 100644 --- a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -86,6 +86,61 @@ class WorkspacesTests { assertNull(defaultAutoGroup.domain) assertNull(defaultAutoGroup.autoGroup) } + + @Test + fun `UpdateWorkspaceRequest only serializes documented update fields`() { + val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) + val request = UpdateWorkspaceRequest.Builder() + .name("Renamed") + .autoGroup(false) + .policyId("policy-123") + .ruleIds(listOf("rule-1")) + .build() + + val json = adapter.toJson(request) + + assert(json.contains("\"name\":\"Renamed\"")) { "Expected name in JSON, got: $json" } + assert(json.contains("\"auto_group\":false")) { "Expected auto_group in JSON, got: $json" } + assert(json.contains("\"policy_id\":\"policy-123\"")) { "Expected policy_id in JSON, got: $json" } + assert(json.contains("\"rule_ids\":[\"rule-1\"]")) { "Expected rule_ids in JSON, got: $json" } + assert(!json.contains("\"domain\"")) { "Expected domain to stay out of update serialization, got: $json" } + } + + @Test + fun `UpdateWorkspaceRequest omits policy id when unchanged`() { + val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) + val request = UpdateWorkspaceRequest.Builder() + .name("Renamed") + .build() + + val json = adapter.toJson(request) + + assert(!json.contains("\"policy_id\"")) { "Expected policy_id to be omitted, got: $json" } + } + + @Test + fun `UpdateWorkspaceRequest serializes explicit null policy detach`() { + val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) + val request = UpdateWorkspaceRequest.Builder() + .policyId(null) + .build() + + val json = adapter.toJson(request) + + assert(json.contains("\"policy_id\":null")) { "Expected policy_id:null in JSON, got: $json" } + } + + @Test + fun `UpdateWorkspaceRequest clearPolicyId serializes explicit null policy detach`() { + val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) + val request = UpdateWorkspaceRequest.Builder() + .clearPolicyId() + .build() + + val json = adapter.toJson(request) + + assert(json.contains("\"policy_id\":null")) { "Expected policy_id:null in JSON, got: $json" } + } } @Nested From 4192f40495fd582b35de9afddd8fa2c8871f5a7e Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:18:00 -0400 Subject: [PATCH 07/23] Fix rules list response parsing --- .../com/nylas/models/RulesListResponse.kt | 27 ---------- src/main/kotlin/com/nylas/resources/Rules.kt | 9 +--- .../kotlin/com/nylas/resources/RulesTests.kt | 49 ++++--------------- 3 files changed, 12 insertions(+), 73 deletions(-) delete mode 100644 src/main/kotlin/com/nylas/models/RulesListResponse.kt diff --git a/src/main/kotlin/com/nylas/models/RulesListResponse.kt b/src/main/kotlin/com/nylas/models/RulesListResponse.kt deleted file mode 100644 index 7f4aaafc..00000000 --- a/src/main/kotlin/com/nylas/models/RulesListResponse.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.nylas.models - -import com.squareup.moshi.Json - -/** - * Class representation of the nested list response returned by the Rules API. - */ -data class RulesListResponse( - @Json(name = "data") - val data: RulesListData? = null, - @Json(name = "request_id") - val requestId: String = "", -) { - fun toListResponse(): ListResponse { - return ListResponse(data?.items ?: emptyList(), requestId, data?.nextCursor) - } -} - -/** - * Class representation of the nested Rules API list payload. - */ -data class RulesListData( - @Json(name = "items") - val items: List? = null, - @Json(name = "next_cursor") - val nextCursor: String? = null, -) diff --git a/src/main/kotlin/com/nylas/resources/Rules.kt b/src/main/kotlin/com/nylas/resources/Rules.kt index 4c2fc390..0b20b34c 100644 --- a/src/main/kotlin/com/nylas/resources/Rules.kt +++ b/src/main/kotlin/com/nylas/resources/Rules.kt @@ -24,13 +24,8 @@ class Rules(client: NylasClient) : Resource(client, Rule::class.java) { @Throws(NylasApiError::class, NylasSdkTimeoutError::class) @JvmOverloads fun list(queryParams: ListRulesQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { - val response = client.executeGet( - "v3/rules", - RulesListResponse::class.java, - queryParams, - overrides, - ) - return response.toListResponse() + val responseType = Types.newParameterizedType(ListResponse::class.java, Rule::class.java) + return client.executeGet("v3/rules", responseType, queryParams, overrides) } /** diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index d8c9e255..e8e6aff7 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -246,38 +246,6 @@ class RulesTests { assertNull(rule.description) } - @Test - fun `RulesListResponse unwraps nested rules list response`() { - val adapter = JsonHelper.moshi().adapter(RulesListResponse::class.java) - val jsonBuffer = Buffer().writeUtf8( - """ - { - "request_id": "req-123", - "data": { - "items": [ - { - "id": "rule-1", - "name": "Block spam", - "actions": [], - "application_id": "app-id", - "organization_id": "org-id", - "created_at": 1000, - "updated_at": 1000 - } - ], - "next_cursor": "cursor-123" - } - } - """.trimIndent(), - ) - - val response = adapter.fromJson(jsonBuffer)!!.toListResponse() - - assertEquals("req-123", response.requestId) - assertEquals("cursor-123", response.nextCursor) - assertEquals("rule-1", response.data.first().id) - } - @Test fun `RuleEvaluation deserializes properly`() { val adapter = JsonHelper.moshi().adapter(RuleEvaluation::class.java) @@ -330,9 +298,10 @@ class RulesTests { @Test fun `listing rules calls requests with the correct params`() { + val responseType = Types.newParameterizedType(ListResponse::class.java, Rule::class.java) whenever( - mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), - ).thenReturn(RulesListResponse()) + mockNylasClient.executeGet>(any(), any(), anyOrNull(), anyOrNull()), + ).thenReturn(ListResponse()) rules.list() @@ -340,7 +309,7 @@ class RulesTests { val typeCaptor = argumentCaptor() val queryParamCaptor = argumentCaptor() val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet( + verify(mockNylasClient).executeGet>( pathCaptor.capture(), typeCaptor.capture(), queryParamCaptor.capture(), @@ -348,16 +317,17 @@ class RulesTests { ) assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(RulesListResponse::class.java, typeCaptor.firstValue) + assertEquals(responseType, typeCaptor.firstValue) assertNull(queryParamCaptor.firstValue) } @Test fun `listing rules with query params passes them correctly`() { val queryParams = ListRulesQueryParams(limit = 5, pageToken = "cursor123") + val responseType = Types.newParameterizedType(ListResponse::class.java, Rule::class.java) whenever( - mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), - ).thenReturn(RulesListResponse()) + mockNylasClient.executeGet>(any(), any(), anyOrNull(), anyOrNull()), + ).thenReturn(ListResponse()) rules.list(queryParams) @@ -365,7 +335,7 @@ class RulesTests { val typeCaptor = argumentCaptor() val queryParamCaptor = argumentCaptor() val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet( + verify(mockNylasClient).executeGet>( pathCaptor.capture(), typeCaptor.capture(), queryParamCaptor.capture(), @@ -373,6 +343,7 @@ class RulesTests { ) assertEquals("v3/rules", pathCaptor.firstValue) + assertEquals(responseType, typeCaptor.firstValue) assertEquals(queryParams, queryParamCaptor.firstValue) } From 69f4a061a27dd41231c74b13012e6579849f3764 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:40:05 -0400 Subject: [PATCH 08/23] Add service account signing for domains --- CHANGELOG.md | 2 +- src/main/kotlin/com/nylas/NylasClient.kt | 6 +- .../interceptors/HttpLoggingInterceptor.kt | 6 +- .../com/nylas/models/RequestOverrides.kt | 5 + .../com/nylas/models/ServiceAccountSigner.kt | 179 ++++++++++++++++++ .../kotlin/com/nylas/resources/Domains.kt | 171 ++++++++++++++++- .../HttpLoggingInterceptorTest.kt | 15 ++ .../nylas/models/ServiceAccountSignerTests.kt | 83 ++++++++ .../com/nylas/resources/DomainsTests.kt | 142 +++++++++++++- 9 files changed, 595 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt create mode 100644 src/test/kotlin/com/nylas/interceptors/HttpLoggingInterceptorTest.kt create mode 100644 src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 231679ea..4c345698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * Application administration updates - `Applications.update()` for `PATCH /v3/applications` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these require Nylas Service Account request-signing headers in `RequestOverrides.headers` + - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth and still accept manually signed headers in `RequestOverrides.headers` - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` diff --git a/src/main/kotlin/com/nylas/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index bb60b668..722aa457 100644 --- a/src/main/kotlin/com/nylas/NylasClient.kt +++ b/src/main/kotlin/com/nylas/NylasClient.kt @@ -361,8 +361,10 @@ open class NylasClient( val builder = Request.Builder().url(url.build()) // Override the API key if it is provided in the override - val apiKey = overrides?.apiKey ?: this.apiKey - builder.addHeader(HttpHeaders.AUTHORIZATION.headerName, "Bearer $apiKey") + if (overrides?.omitAuthorization != true) { + val apiKey = overrides?.apiKey ?: this.apiKey + builder.addHeader(HttpHeaders.AUTHORIZATION.headerName, "Bearer $apiKey") + } // Add additional headers if (overrides?.headers != null) { diff --git a/src/main/kotlin/com/nylas/interceptors/HttpLoggingInterceptor.kt b/src/main/kotlin/com/nylas/interceptors/HttpLoggingInterceptor.kt index 71708b87..2071aa44 100644 --- a/src/main/kotlin/com/nylas/interceptors/HttpLoggingInterceptor.kt +++ b/src/main/kotlin/com/nylas/interceptors/HttpLoggingInterceptor.kt @@ -118,7 +118,7 @@ class HttpLoggingInterceptor : Interceptor { for (i in 0 until headers.size) { val name = headers.name(i) var value = headers.value(i) - if (!isLogAuthHeader && "Authorization" == name) { + if ((!isLogAuthHeader && "Authorization" == name) || shouldRedactHeader(name)) { value = "" } headersLog.append(" ").append(name).append(": ").append(value).append("\n") @@ -166,5 +166,9 @@ class HttpLoggingInterceptor : Interceptor { private val requestLogs = LoggerFactory.getLogger("com.nylas.http.Summary") private val headersLogs = LoggerFactory.getLogger("com.nylas.http.Headers") private val bodyLogs = LoggerFactory.getLogger("com.nylas.http.Body") + + internal fun shouldRedactHeader(name: String): Boolean { + return "X-Nylas-Signature".equals(name, ignoreCase = true) + } } } diff --git a/src/main/kotlin/com/nylas/models/RequestOverrides.kt b/src/main/kotlin/com/nylas/models/RequestOverrides.kt index fd050b80..f52a6d46 100644 --- a/src/main/kotlin/com/nylas/models/RequestOverrides.kt +++ b/src/main/kotlin/com/nylas/models/RequestOverrides.kt @@ -20,6 +20,11 @@ data class RequestOverrides( * Additional headers to include in the request. */ val headers: Map? = emptyMap(), + /** + * Omit the default Authorization header for requests that use another authentication scheme. + * @suppress Not for public use. + */ + val omitAuthorization: Boolean = false, ) { /** * Builder for [RequestOverrides]. diff --git a/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt b/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt new file mode 100644 index 00000000..f124afc3 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt @@ -0,0 +1,179 @@ +package com.nylas.models + +import com.nylas.util.JsonHelper +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.RSAPrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 + +/** + * Builds Nylas Service Account request-signing headers for organization admin APIs. + * + * For POST, PUT, and PATCH requests, [buildHeaders] also returns the canonical JSON + * body string that must be sent on the wire for the signature to match. + */ +class ServiceAccountSigner(privateKey: PrivateKey, private val privateKeyId: String) { + private val privateKey: RSAPrivateKey + + init { + require(privateKey is RSAPrivateKey) { "Private key must be RSA" } + this.privateKey = privateKey + } + + /** + * Create a signer from an RSA private key in PKCS#8 PEM format. + * + * @param privateKeyPem RSA private key PEM text. + * @param privateKeyId Value to send as `X-Nylas-Kid`. + */ + constructor(privateKeyPem: String, privateKeyId: String) : this(loadPrivateKeyFromPem(privateKeyPem), privateKeyId) + + /** + * Build the four required Nylas Service Account signing headers. + * + * @param method HTTP method. + * @param path Request path including `/v3`, without query string. + * @param body Request body object for POST, PUT, and PATCH requests. + * @param timestamp Optional Unix timestamp in seconds. Defaults to current time. + * @param nonce Optional nonce. Defaults to a secure random alphanumeric nonce. + * @return Signing headers and optional canonical JSON body. + */ + @JvmOverloads + fun buildHeaders( + method: String, + path: String, + body: Any? = null, + timestamp: Long? = null, + nonce: String? = null, + ): ServiceAccountSigningResult { + val normalizedMethod = method.lowercase() + val timestampSeconds = timestamp ?: (System.currentTimeMillis() / 1000) + val nonceValue = nonce ?: generateNonce() + val canonicalBody = if (normalizedMethod in BODY_METHODS && body != null) canonicalJson(body) else null + val envelope = linkedMapOf( + "method" to normalizedMethod, + "nonce" to nonceValue, + "path" to path, + "timestamp" to timestampSeconds, + ) + if (canonicalBody != null) { + envelope["payload"] = canonicalBody + } + + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(privateKey) + signature.update(canonicalJson(envelope).toByteArray(Charsets.UTF_8)) + val encodedSignature = Base64.getEncoder().encodeToString(signature.sign()) + + return ServiceAccountSigningResult( + headers = mapOf( + "X-Nylas-Kid" to privateKeyId, + "X-Nylas-Nonce" to nonceValue, + "X-Nylas-Timestamp" to timestampSeconds.toString(), + "X-Nylas-Signature" to encodedSignature, + ), + serializedJsonBody = canonicalBody, + ) + } + + companion object { + private val BODY_METHODS = setOf("post", "put", "patch") + private val NONCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray() + private const val NONCE_LENGTH = 20 + private val secureRandom = SecureRandom() + + /** + * Deterministic JSON with object keys sorted recursively and no extra whitespace. + */ + fun canonicalJson(value: Any?): String { + return when (value) { + null -> "null" + is Map<*, *> -> + value.entries + .sortedBy { it.key.toString() } + .joinToString(prefix = "{", postfix = "}", separator = ",") { (key, itemValue) -> + "${jsonString(key.toString())}:${canonicalJson(itemValue)}" + } + is Iterable<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalJson(it) } + is Array<*> -> value.joinToString(prefix = "[", postfix = "]", separator = ",") { canonicalJson(it) } + is String -> jsonString(value) + is Number, is Boolean -> JsonHelper.moshi().adapter(Any::class.java).toJson(value) + else -> { + val jsonValue = JsonHelper.moshi().adapter(value.javaClass).toJsonValue(value) + canonicalJson(jsonValue) + } + } + } + + fun generateNonce(length: Int = NONCE_LENGTH): String { + require(length > 0) { "Nonce length must be positive" } + return CharArray(length) { + NONCE_ALPHABET[secureRandom.nextInt(NONCE_ALPHABET.size)] + }.concatToString() + } + + fun loadPrivateKeyFromPem(privateKeyPem: String): RSAPrivateKey { + val isPkcs1 = privateKeyPem.contains("BEGIN RSA PRIVATE KEY") + val keyBytes = privateKeyPem + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + val decoded = Base64.getDecoder().decode(keyBytes) + val pkcs8Bytes = if (isPkcs1) pkcs1ToPkcs8(decoded) else decoded + val key = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8Bytes)) + require(key is RSAPrivateKey) { "Private key must be RSA" } + return key + } + + private fun jsonString(value: String): String { + return JsonHelper.moshi().adapter(String::class.java).toJson(value) + } + + private fun pkcs1ToPkcs8(pkcs1: ByteArray): ByteArray { + val version = byteArrayOf(0x02, 0x01, 0x00) + val rsaEncryptionAlgorithm = byteArrayOf( + 0x30, 0x0d, + 0x06, 0x09, + 0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x01, 0x01, + 0x05, 0x00, + ) + return derSequence(version, rsaEncryptionAlgorithm, derOctetString(pkcs1)) + } + + private fun derSequence(vararg parts: ByteArray): ByteArray { + val body = parts.fold(byteArrayOf()) { acc, part -> acc + part } + return byteArrayOf(0x30) + derLength(body.size) + body + } + + private fun derOctetString(value: ByteArray): ByteArray { + return byteArrayOf(0x04) + derLength(value.size) + value + } + + private fun derLength(length: Int): ByteArray { + if (length < 128) { + return byteArrayOf(length.toByte()) + } + + val bytes = mutableListOf() + var remaining = length + while (remaining > 0) { + bytes.add(0, (remaining and 0xff).toByte()) + remaining = remaining ushr 8 + } + return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray() + } + } +} + +/** + * Result of signing a Nylas Service Account request. + */ +data class ServiceAccountSigningResult( + val headers: Map, + val serializedJsonBody: String? = null, +) diff --git a/src/main/kotlin/com/nylas/resources/Domains.kt b/src/main/kotlin/com/nylas/resources/Domains.kt index c273e27a..ee0a9c43 100644 --- a/src/main/kotlin/com/nylas/resources/Domains.kt +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -8,8 +8,8 @@ import com.nylas.util.PathEncoder import com.squareup.moshi.Types class Domains(client: NylasClient) : Resource(client, Message::class.java) { - private fun requireServiceAccountSigning(overrides: RequestOverrides): RequestOverrides { - val headers = overrides.headers.orEmpty() + private fun requireServiceAccountSigning(overrides: RequestOverrides?): RequestOverrides { + val headers = overrides?.headers.orEmpty() val normalizedHeaders = headers.entries.associate { it.key.lowercase() to it.value } val missingHeaders = SERVICE_ACCOUNT_SIGNING_HEADERS.filter { normalizedHeaders[it.lowercase()].isNullOrBlank() @@ -19,7 +19,24 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja "Manage Domains endpoints require Nylas Service Account signing headers: ${missingHeaders.joinToString()}" } - return overrides + return (overrides ?: RequestOverrides()).copy(omitAuthorization = true) + } + + private fun mergeSignerOverrides(overrides: RequestOverrides?, signerHeaders: Map): RequestOverrides { + val headers = overrides?.headers.orEmpty().toMutableMap() + headers.putAll(signerHeaders) + return (overrides ?: RequestOverrides()).copy(headers = headers, omitAuthorization = true) + } + + private fun signedRequest( + method: NylasClient.HttpMethod, + path: String, + signer: ServiceAccountSigner, + body: Any? = null, + overrides: RequestOverrides? = null, + ): Pair { + val signingResult = signer.buildHeaders(method.toString(), "/$path", body) + return Pair(mergeSignerOverrides(overrides, signingResult.headers), signingResult.serializedJsonBody) } /** @@ -62,6 +79,18 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return list(null, overrides) } + /** + * Return all managed domains for the caller's organization. + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The list of managed domains + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun list(signer: ServiceAccountSigner, overrides: RequestOverrides? = null): ListResponse { + return list(null, signer, overrides) + } + /** * Return all managed domains for the caller's organization. * @param queryParams Optional query parameters to apply @@ -75,6 +104,25 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executeGet("v3/admin/domains", responseType, queryParams, signedOverrides) } + /** + * Return all managed domains for the caller's organization. + * @param queryParams Optional query parameters to apply + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The list of managed domains + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun list( + queryParams: ListDomainsQueryParams?, + signer: ServiceAccountSigner, + overrides: RequestOverrides? = null, + ): ListResponse { + val (signedOverrides) = signedRequest(NylasClient.HttpMethod.GET, "v3/admin/domains", signer, overrides = overrides) + val responseType = Types.newParameterizedType(ListResponse::class.java, Domain::class.java) + return client.executeGet("v3/admin/domains", responseType, queryParams, signedOverrides) + } + /** * Return a managed domain. * @param domainId The ID or domain address of the domain to retrieve @@ -89,6 +137,22 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executeGet(path, responseType, overrides = signedOverrides) } + /** + * Return a managed domain. + * @param domainId The ID or domain address of the domain to retrieve + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The managed domain + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun find(domainId: String, signer: ServiceAccountSigner, overrides: RequestOverrides? = null): Response { + val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) + val (signedOverrides) = signedRequest(NylasClient.HttpMethod.GET, path, signer, overrides = overrides) + val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) + return client.executeGet(path, responseType, overrides = signedOverrides) + } + /** * Create a managed domain. * @param requestBody The values to create the domain with @@ -104,6 +168,26 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } + /** + * Create a managed domain. + * @param requestBody The values to create the domain with + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The created managed domain + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun create( + requestBody: CreateDomainRequest, + signer: ServiceAccountSigner, + overrides: RequestOverrides? = null, + ): Response { + val path = "v3/admin/domains" + val (signedOverrides, serializedRequestBody) = signedRequest(NylasClient.HttpMethod.POST, path, signer, requestBody, overrides) + val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) + return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) + } + /** * Update a managed domain. * @param domainId The ID or domain address of the domain to update @@ -120,6 +204,28 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executePut(path, responseType, serializedRequestBody, overrides = signedOverrides) } + /** + * Update a managed domain. + * @param domainId The ID or domain address of the domain to update + * @param requestBody The values to update the domain with + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The updated managed domain fields + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun update( + domainId: String, + requestBody: UpdateDomainRequest, + signer: ServiceAccountSigner, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) + val (signedOverrides, serializedRequestBody) = signedRequest(NylasClient.HttpMethod.PUT, path, signer, requestBody, overrides) + val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) + return client.executePut(path, responseType, serializedRequestBody, overrides = signedOverrides) + } + /** * Delete a managed domain. * @param domainId The ID or domain address of the domain to delete @@ -133,6 +239,21 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executeDelete(path, DeleteResponse::class.java, overrides = signedOverrides) } + /** + * Delete a managed domain. + * @param domainId The ID or domain address of the domain to delete + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The deletion response + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun destroy(domainId: String, signer: ServiceAccountSigner, overrides: RequestOverrides? = null): DeleteResponse { + val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) + val (signedOverrides) = signedRequest(NylasClient.HttpMethod.DELETE, path, signer, overrides = overrides) + return client.executeDelete(path, DeleteResponse::class.java, overrides = signedOverrides) + } + /** * Get DNS record information for a domain verification type. * @param domainId The ID or domain address of the domain @@ -153,6 +274,28 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } + /** + * Get DNS record information for a domain verification type. + * @param domainId The ID or domain address of the domain + * @param requestBody The verification type to inspect + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The domain verification result + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun info( + domainId: String, + requestBody: DomainVerificationRequest, + signer: ServiceAccountSigner, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/admin/domains/%s/info", PathEncoder.encode(domainId)) + val (signedOverrides, serializedRequestBody) = signedRequest(NylasClient.HttpMethod.POST, path, signer, requestBody, overrides) + val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) + return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) + } + /** * Trigger a DNS verification check for a domain verification type. * @param domainId The ID or domain address of the domain @@ -173,6 +316,28 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } + /** + * Trigger a DNS verification check for a domain verification type. + * @param domainId The ID or domain address of the domain + * @param requestBody The verification type to verify + * @param signer Service Account signer used to generate Nylas request-signing headers + * @param overrides Optional request overrides to merge with signer headers + * @return The domain verification result + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun verify( + domainId: String, + requestBody: DomainVerificationRequest, + signer: ServiceAccountSigner, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/admin/domains/%s/verify", PathEncoder.encode(domainId)) + val (signedOverrides, serializedRequestBody) = signedRequest(NylasClient.HttpMethod.POST, path, signer, requestBody, overrides) + val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) + return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) + } + companion object { private val SERVICE_ACCOUNT_SIGNING_HEADERS = listOf( "X-Nylas-Kid", diff --git a/src/test/kotlin/com/nylas/interceptors/HttpLoggingInterceptorTest.kt b/src/test/kotlin/com/nylas/interceptors/HttpLoggingInterceptorTest.kt new file mode 100644 index 00000000..96b55667 --- /dev/null +++ b/src/test/kotlin/com/nylas/interceptors/HttpLoggingInterceptorTest.kt @@ -0,0 +1,15 @@ +package com.nylas.interceptors + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HttpLoggingInterceptorTest { + @Test + fun `redacts service account signature headers`() { + assertTrue(HttpLoggingInterceptor.shouldRedactHeader("X-Nylas-Signature")) + assertTrue(HttpLoggingInterceptor.shouldRedactHeader("x-nylas-signature")) + assertFalse(HttpLoggingInterceptor.shouldRedactHeader("X-Nylas-Kid")) + assertFalse(HttpLoggingInterceptor.shouldRedactHeader("X-Nylas-Nonce")) + } +} diff --git a/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt b/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt new file mode 100644 index 00000000..5dbf5a8e --- /dev/null +++ b/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt @@ -0,0 +1,83 @@ +package com.nylas.models + +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ServiceAccountSignerTests { + @Test + fun `canonical json sorts object keys recursively without extra whitespace`() { + val json = ServiceAccountSigner.canonicalJson( + mapOf( + "z" to mapOf("b" to 1, "a" to 2), + "items" to listOf(mapOf("d" to true, "c" to "value")), + "a" to 0, + ), + ) + + assertEquals("""{"a":0,"items":[{"c":"value","d":true}],"z":{"a":2,"b":1}}""", json) + } + + @Test + fun `canonical json uses sdk json names for request models`() { + val json = ServiceAccountSigner.canonicalJson(DomainVerificationRequest(DomainVerificationType.SPF)) + + assertEquals("""{"type":"spf"}""", json) + } + + @Test + fun `build headers is deterministic with fixed timestamp and nonce`() { + val keyPair = testKeyPair() + val signer = ServiceAccountSigner(keyPair.private, "kid-123") + val body = mapOf("name" to "My domain", "domain_address" to "mail.example.com") + + val first = signer.buildHeaders("POST", "/v3/admin/domains", body, timestamp = 1700000000, nonce = "nonce123456789012345") + val second = signer.buildHeaders("POST", "/v3/admin/domains", body, timestamp = 1700000000, nonce = "nonce123456789012345") + + assertEquals(first, second) + assertEquals("kid-123", first.headers["X-Nylas-Kid"]) + assertEquals("1700000000", first.headers["X-Nylas-Timestamp"]) + assertEquals("nonce123456789012345", first.headers["X-Nylas-Nonce"]) + assertEquals("""{"domain_address":"mail.example.com","name":"My domain"}""", first.serializedJsonBody) + assertNotNull(first.headers["X-Nylas-Signature"]) + assertTrue(verifySignature(keyPair, signingEnvelope(first, "/v3/admin/domains", "post"), first.headers.getValue("X-Nylas-Signature"))) + } + + @Test + fun `get requests include no canonical body`() { + val signer = ServiceAccountSigner(testKeyPair().private, "kid") + val result = signer.buildHeaders("GET", "/v3/admin/domains", timestamp = 1, nonce = "n".repeat(20)) + + assertEquals(null, result.serializedJsonBody) + assertTrue(result.headers.getValue("X-Nylas-Signature").isNotBlank()) + } + + private fun signingEnvelope(result: ServiceAccountSigningResult, path: String, method: String): ByteArray { + val envelope = linkedMapOf( + "method" to method, + "nonce" to result.headers.getValue("X-Nylas-Nonce"), + "path" to path, + "timestamp" to result.headers.getValue("X-Nylas-Timestamp").toLong(), + ) + result.serializedJsonBody?.let { envelope["payload"] = it } + return ServiceAccountSigner.canonicalJson(envelope).toByteArray(Charsets.UTF_8) + } + + private fun verifySignature(keyPair: KeyPair, message: ByteArray, signature: String): Boolean { + val verifier = Signature.getInstance("SHA256withRSA") + verifier.initVerify(keyPair.public) + verifier.update(message) + return verifier.verify(Base64.getDecoder().decode(signature)) + } + + private fun testKeyPair(): KeyPair { + val generator = KeyPairGenerator.getInstance("RSA") + generator.initialize(2048) + return generator.generateKeyPair() + } +} diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index 8e20e4af..083542a1 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -6,6 +6,8 @@ import com.nylas.models.Response import com.nylas.util.JsonHelper import com.squareup.moshi.Types import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody import okio.Buffer import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -13,11 +15,17 @@ import org.mockito.Mockito import org.mockito.kotlin.* import java.io.ByteArrayInputStream import java.lang.reflect.Type +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.util.Base64 +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue class DomainsTests { @@ -364,7 +372,7 @@ class DomainsTests { assertEquals("v3/admin/domains", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(ListResponse::class.java, Domain::class.java), typeCaptor.firstValue) assertEquals(queryParams, queryParamCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test @@ -378,6 +386,96 @@ class DomainsTests { } } + @Test + fun `managed domain list passes service account signing headers through request overrides`() { + val capturedRequest = AtomicReference() + val httpClientBuilder = OkHttpClient.Builder().addInterceptor { chain -> + val request = chain.request() + capturedRequest.set(request) + okhttp3.Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"data":[],"request_id":"req-123"}""".toResponseBody("application/json".toMediaType())) + .build() + } + val client = NylasClient("api-key", httpClientBuilder, "https://api.test.nylas.com/") + val overrides = serviceAccountOverrides() + + client.domains().list(overrides) + + val request = capturedRequest.get() + assertNull(request.header("Authorization")) + assertEquals("service-account-id", request.header("X-Nylas-Kid")) + assertEquals("1742932766", request.header("X-Nylas-Timestamp")) + assertEquals("nonce", request.header("X-Nylas-Nonce")) + assertEquals("signature", request.header("X-Nylas-Signature")) + } + + @Test + fun `non-domain requests keep bearer authorization`() { + val capturedRequest = AtomicReference() + val httpClientBuilder = OkHttpClient.Builder().addInterceptor { chain -> + val request = chain.request() + capturedRequest.set(request) + okhttp3.Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"data":[],"request_id":"req-123"}""".toResponseBody("application/json".toMediaType())) + .build() + } + val client = NylasClient("api-key", httpClientBuilder, "https://api.test.nylas.com/") + + client.grants().list() + + assertEquals("Bearer api-key", capturedRequest.get().header("Authorization")) + } + + @Test + fun `creating managed domain with signer sends canonical body and generated signing headers`() { + val keyPair = testKeyPair() + val capturedRequest = AtomicReference() + val capturedBody = AtomicReference() + val httpClientBuilder = OkHttpClient.Builder().addInterceptor { chain -> + val request = chain.request() + val buffer = Buffer() + request.body!!.writeTo(buffer) + capturedRequest.set(request) + capturedBody.set(buffer.readUtf8()) + okhttp3.Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"data":{"id":"dom-123"},"request_id":"req-123"}""".toResponseBody("application/json".toMediaType())) + .build() + } + val client = NylasClient("api-key", httpClientBuilder, "https://api.test.nylas.com/") + val signer = ServiceAccountSigner(keyPair.private, "service-account-key-id") + + client.domains().create( + CreateDomainRequest(name = "Acme", domainAddress = "mail.acme.com"), + signer, + RequestOverrides(headers = mapOf("X-Custom" to "keep")), + ) + + val request = capturedRequest.get() + val body = capturedBody.get() + assertEquals("""{"domain_address":"mail.acme.com","name":"Acme"}""", body) + assertNull(request.header("Authorization")) + assertEquals("keep", request.header("X-Custom")) + assertEquals("service-account-key-id", request.header("X-Nylas-Kid")) + assertTrue(request.header("X-Nylas-Nonce")!!.isNotBlank()) + assertTrue(request.header("X-Nylas-Timestamp")!!.isNotBlank()) + assertTrue( + verifyServiceAccountSignature(keyPair, request, body), + "Expected generated service account signature to verify against canonical request data", + ) + } + @Test fun `finding a managed domain calls requests with the correct params`() { val overrides = serviceAccountOverrides() @@ -396,7 +494,7 @@ class DomainsTests { assertEquals("v3/admin/domains/domain%2Fwith%2Fslash", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test @@ -422,7 +520,7 @@ class DomainsTests { assertEquals("v3/admin/domains", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test @@ -448,7 +546,7 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test @@ -469,7 +567,7 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) assertEquals(DeleteResponse::class.java, typeCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test @@ -495,7 +593,7 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123/info", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test @@ -521,7 +619,37 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123/verify", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) - assertEquals(overrides, overrideParamCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) + } + + private fun assertServiceAccountOverrides(expected: RequestOverrides, actual: RequestOverrides) { + assertEquals(expected.apiKey, actual.apiKey) + assertEquals(expected.apiUri, actual.apiUri) + assertEquals(expected.timeout, actual.timeout) + assertEquals(expected.headers, actual.headers) + assertTrue(actual.omitAuthorization) + } + + private fun verifyServiceAccountSignature(keyPair: KeyPair, request: Request, body: String?): Boolean { + val envelope = linkedMapOf( + "method" to request.method.lowercase(), + "nonce" to request.header("X-Nylas-Nonce")!!, + "path" to request.url.encodedPath, + "timestamp" to request.header("X-Nylas-Timestamp")!!.toLong(), + ) + if (body != null) { + envelope["payload"] = body + } + val verifier = Signature.getInstance("SHA256withRSA") + verifier.initVerify(keyPair.public) + verifier.update(ServiceAccountSigner.canonicalJson(envelope).toByteArray(Charsets.UTF_8)) + return verifier.verify(Base64.getDecoder().decode(request.header("X-Nylas-Signature"))) + } + + private fun testKeyPair(): KeyPair { + val generator = KeyPairGenerator.getInstance("RSA") + generator.initialize(2048) + return generator.generateKeyPair() } } } From 5ce8bbf810558cce4dcf9d7bc7bd6eadbceb520b Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:47:46 -0400 Subject: [PATCH 09/23] Add workspace list pagination params --- CHANGELOG.md | 2 +- .../nylas/models/ListWorkspacesQueryParams.kt | 47 +++++++++++++++++++ .../kotlin/com/nylas/resources/Workspaces.kt | 5 +- .../com/nylas/resources/WorkspacesTests.kt | 22 +++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/com/nylas/models/ListWorkspacesQueryParams.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c345698..03bc30dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `Applications.update()` for `PATCH /v3/applications` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth and still accept manually signed headers in `RequestOverrides.headers` - - `Workspaces` resource via `client.workspaces()`: CRUD, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true + - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` - `NylasClient.domains()` accessor returning the new `Domains` resource diff --git a/src/main/kotlin/com/nylas/models/ListWorkspacesQueryParams.kt b/src/main/kotlin/com/nylas/models/ListWorkspacesQueryParams.kt new file mode 100644 index 00000000..2bab9892 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/ListWorkspacesQueryParams.kt @@ -0,0 +1,47 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of the query parameters for listing workspaces. + */ +data class ListWorkspacesQueryParams( + /** + * The maximum number of objects to return. + */ + @Json(name = "limit") + val limit: Int? = null, + /** + * Cursor for pagination. Pass the value of [ListResponse.nextCursor] to get the next page. + */ + @Json(name = "page_token") + val pageToken: String? = null, +) : IQueryParams { + /** + * Builder for [ListWorkspacesQueryParams]. + */ + class Builder { + private var limit: Int? = null + private var pageToken: String? = null + + /** + * Set the maximum number of objects to return. + * @param limit The maximum number of objects to return. + * @return The builder. + */ + fun limit(limit: Int) = apply { this.limit = limit } + + /** + * Set the pagination cursor. + * @param pageToken Cursor for pagination. Pass the value of [ListResponse.nextCursor]. + * @return The builder. + */ + fun pageToken(pageToken: String) = apply { this.pageToken = pageToken } + + /** + * Build the [ListWorkspacesQueryParams]. + * @return A [ListWorkspacesQueryParams] with the provided values. + */ + fun build() = ListWorkspacesQueryParams(limit, pageToken) + } +} diff --git a/src/main/kotlin/com/nylas/resources/Workspaces.kt b/src/main/kotlin/com/nylas/resources/Workspaces.kt index ca4eb515..cf1fbd27 100644 --- a/src/main/kotlin/com/nylas/resources/Workspaces.kt +++ b/src/main/kotlin/com/nylas/resources/Workspaces.kt @@ -17,13 +17,14 @@ import com.squareup.moshi.Types class Workspaces(client: NylasClient) : Resource(client, Workspace::class.java) { /** * Return all workspaces for your application. + * @param queryParams Optional query parameters to apply * @param overrides Optional request overrides to apply * @return The list of workspaces */ @Throws(NylasApiError::class, NylasSdkTimeoutError::class) @JvmOverloads - fun list(overrides: RequestOverrides? = null): ListResponse { - return listResource("v3/workspaces", overrides = overrides) + fun list(queryParams: ListWorkspacesQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { + return listResource("v3/workspaces", queryParams, overrides) } /** diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt index e263708b..8d48157c 100644 --- a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -171,6 +171,28 @@ class WorkspacesTests { assertEquals("v3/workspaces", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(ListResponse::class.java, Workspace::class.java), typeCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `listing workspaces with query params passes them correctly`() { + val queryParams = ListWorkspacesQueryParams(limit = 5, pageToken = "cursor123") + workspaces.list(queryParams) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeGet>( + pathCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/workspaces", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(ListResponse::class.java, Workspace::class.java), typeCaptor.firstValue) + assertEquals(queryParams, queryParamCaptor.firstValue) } @Test From 66d868cb403fd50a0a2811551418655cc7ea97c5 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:52:42 -0400 Subject: [PATCH 10/23] Support callback URIs in application updates --- CHANGELOG.md | 1 + .../nylas/models/UpdateApplicationRequest.kt | 15 ++++++++++++- .../com/nylas/resources/ApplicationsTests.kt | 22 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bc30dd..9437616d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added * Application administration updates - `Applications.update()` for `PATCH /v3/applications` + - Application updates support `callback_uris` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth and still accept manually signed headers in `RequestOverrides.headers` - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true diff --git a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt index 001390a8..47b77da9 100644 --- a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt @@ -16,6 +16,11 @@ data class UpdateApplicationRequest( */ @Json(name = "hosted_authentication") val hostedAuthentication: ApplicationDetails.HostedAuthentication? = null, + /** + * List of callback URIs for the application. + */ + @Json(name = "callback_uris") + val callbackUris: List? = null, ) { /** * Builder for [UpdateApplicationRequest]. @@ -23,6 +28,7 @@ data class UpdateApplicationRequest( class Builder { private var branding: ApplicationDetails.Branding? = null private var hostedAuthentication: ApplicationDetails.HostedAuthentication? = null + private var callbackUris: List? = null /** * Set branding details for the application. @@ -39,10 +45,17 @@ data class UpdateApplicationRequest( fun hostedAuthentication(hostedAuthentication: ApplicationDetails.HostedAuthentication?) = apply { this.hostedAuthentication = hostedAuthentication } + /** + * Set callback URIs for the application. + * @param callbackUris List of callback URIs. + * @return This builder. + */ + fun callbackUris(callbackUris: List?) = apply { this.callbackUris = callbackUris } + /** * Build the [UpdateApplicationRequest]. * @return The built [UpdateApplicationRequest]. */ - fun build() = UpdateApplicationRequest(branding, hostedAuthentication) + fun build() = UpdateApplicationRequest(branding, hostedAuthentication, callbackUris) } } diff --git a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt index c8648d30..f35925b5 100644 --- a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt @@ -116,6 +116,28 @@ class ApplicationsTests { assertEquals("string", app.callbackUris?.get(0)?.settings?.packageName) assertEquals("string", app.callbackUris?.get(0)?.settings?.sha1CertificateFingerprint) } + + @Test + fun `UpdateApplicationRequest serializes callback URIs`() { + val adapter = JsonHelper.moshi().adapter(UpdateApplicationRequest::class.java) + val request = UpdateApplicationRequest.Builder() + .callbackUris( + listOf( + RedirectUri( + id = "0556d035-6cb6-4262-a035-6b77e11cf8fc", + url = "https://example.com/callback", + platform = Platform.WEB, + ), + ), + ) + .build() + + val json = adapter.toJson(request) + + assert(json.contains("\"callback_uris\"")) { "Expected callback_uris in JSON, got: $json" } + assert(json.contains("\"url\":\"https://example.com/callback\"")) { "Expected callback URL in JSON, got: $json" } + assert(json.contains("\"platform\":\"web\"")) { "Expected platform in JSON, got: $json" } + } } @Nested From a157d8754b437f6f5456b0043b8321037270ae41 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:01:03 -0400 Subject: [PATCH 11/23] Canonicalize signed domain request bodies --- CHANGELOG.md | 2 +- src/main/kotlin/com/nylas/resources/Domains.kt | 8 ++++---- src/test/kotlin/com/nylas/resources/DomainsTests.kt | 12 ++++-------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9437616d..4381509b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `Applications.update()` for `PATCH /v3/applications` - Application updates support `callback_uris` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth and still accept manually signed headers in `RequestOverrides.headers` + - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth, canonical signed wire bodies, and manually signed headers in `RequestOverrides.headers` - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` diff --git a/src/main/kotlin/com/nylas/resources/Domains.kt b/src/main/kotlin/com/nylas/resources/Domains.kt index ee0a9c43..17e107ca 100644 --- a/src/main/kotlin/com/nylas/resources/Domains.kt +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -164,7 +164,7 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja val signedOverrides = requireServiceAccountSigning(overrides) val path = "v3/admin/domains" val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) - val serializedRequestBody = JsonHelper.moshi().adapter(CreateDomainRequest::class.java).toJson(requestBody) + val serializedRequestBody = ServiceAccountSigner.canonicalJson(requestBody) return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } @@ -200,7 +200,7 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, Domain::class.java) - val serializedRequestBody = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java).toJson(requestBody) + val serializedRequestBody = ServiceAccountSigner.canonicalJson(requestBody) return client.executePut(path, responseType, serializedRequestBody, overrides = signedOverrides) } @@ -270,7 +270,7 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s/info", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) - val serializedRequestBody = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java).toJson(requestBody) + val serializedRequestBody = ServiceAccountSigner.canonicalJson(requestBody) return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } @@ -312,7 +312,7 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja val signedOverrides = requireServiceAccountSigning(overrides) val path = String.format("v3/admin/domains/%s/verify", PathEncoder.encode(domainId)) val responseType = Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java) - val serializedRequestBody = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java).toJson(requestBody) + val serializedRequestBody = ServiceAccountSigner.canonicalJson(requestBody) return client.executePost(path, responseType, serializedRequestBody, overrides = signedOverrides) } diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index 083542a1..f065668b 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -499,7 +499,6 @@ class DomainsTests { @Test fun `creating a managed domain calls requests with the correct params`() { - val adapter = JsonHelper.moshi().adapter(CreateDomainRequest::class.java) val requestBody = CreateDomainRequest(name = "Acme", domainAddress = "mail.acme.com") val overrides = serviceAccountOverrides() domains.create(requestBody, overrides) @@ -519,13 +518,12 @@ class DomainsTests { assertEquals("v3/admin/domains", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) - assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals("""{"domain_address":"mail.acme.com","name":"Acme"}""", requestBodyCaptor.firstValue) assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test fun `updating a managed domain calls requests with the correct params`() { - val adapter = JsonHelper.moshi().adapter(UpdateDomainRequest::class.java) val requestBody = UpdateDomainRequest(name = "Renamed") val overrides = serviceAccountOverrides() domains.update("dom-123", requestBody, overrides) @@ -545,7 +543,7 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, Domain::class.java), typeCaptor.firstValue) - assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals("""{"name":"Renamed"}""", requestBodyCaptor.firstValue) assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @@ -572,7 +570,6 @@ class DomainsTests { @Test fun `getting managed domain info calls requests with the correct params`() { - val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) val requestBody = DomainVerificationRequest(DomainVerificationType.SPF) val overrides = serviceAccountOverrides() domains.info("dom-123", requestBody, overrides) @@ -592,13 +589,12 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123/info", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) - assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals("""{"type":"spf"}""", requestBodyCaptor.firstValue) assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } @Test fun `verifying a managed domain calls requests with the correct params`() { - val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) val requestBody = DomainVerificationRequest(DomainVerificationType.DKIM) val overrides = serviceAccountOverrides() domains.verify("dom-123", requestBody, overrides) @@ -618,7 +614,7 @@ class DomainsTests { assertEquals("v3/admin/domains/dom-123/verify", pathCaptor.firstValue) assertEquals(Types.newParameterizedType(Response::class.java, DomainVerificationResult::class.java), typeCaptor.firstValue) - assertEquals(adapter.toJson(requestBody), requestBodyCaptor.firstValue) + assertEquals("""{"type":"dkim"}""", requestBodyCaptor.firstValue) assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) } From 20cbcfd424c506c7443f9d81aa129f335d405818 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:07:57 -0400 Subject: [PATCH 12/23] Address Java admin API review feedback --- CHANGELOG.md | 4 +-- .../com/nylas/models/DomainVerification.kt | 24 ++++++++++++-- .../com/nylas/models/ServiceAccountSigner.kt | 20 +++++++++-- .../nylas/models/UpdateApplicationRequest.kt | 33 +++++++++++++++++-- .../nylas/models/ServiceAccountSignerTests.kt | 16 ++++++++- .../com/nylas/resources/ApplicationsTests.kt | 14 +++++++- .../com/nylas/resources/DomainsTests.kt | 4 +-- 7 files changed, 102 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4381509b..99ef4ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ ### Added * Application administration updates - `Applications.update()` for `PATCH /v3/applications` - - Application updates support `callback_uris` + - Application updates support sparse branding fields and `callback_uris` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth, canonical signed wire bodies, and manually signed headers in `RequestOverrides.headers` + - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth, canonical signed wire bodies, manually signed headers in `RequestOverrides.headers`, base64-encoded PEM service-account keys, and request-only verification types - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` diff --git a/src/main/kotlin/com/nylas/models/DomainVerification.kt b/src/main/kotlin/com/nylas/models/DomainVerification.kt index 87430c91..a1042b5c 100644 --- a/src/main/kotlin/com/nylas/models/DomainVerification.kt +++ b/src/main/kotlin/com/nylas/models/DomainVerification.kt @@ -28,6 +28,26 @@ enum class DomainVerificationType { ARC, } +/** + * DNS verification types accepted by Manage Domains verification requests. + */ +enum class DomainVerificationRequestType { + @Json(name = "ownership") + OWNERSHIP, + + @Json(name = "mx") + MX, + + @Json(name = "spf") + SPF, + + @Json(name = "dkim") + DKIM, + + @Json(name = "feedback") + FEEDBACK, +} + /** * Status values returned by domain DNS verification attempts. */ @@ -47,14 +67,14 @@ enum class DomainVerificationStatus { */ data class DomainVerificationRequest( @Json(name = "type") - val type: DomainVerificationType, + val type: DomainVerificationRequestType, @Json(name = "options") val options: Map? = null, ) { /** * Builder for [DomainVerificationRequest]. */ - data class Builder(private val type: DomainVerificationType) { + data class Builder(private val type: DomainVerificationRequestType) { private var options: Map? = null /** diff --git a/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt b/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt index f124afc3..847793bd 100644 --- a/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt +++ b/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt @@ -116,8 +116,9 @@ class ServiceAccountSigner(privateKey: PrivateKey, private val privateKeyId: Str } fun loadPrivateKeyFromPem(privateKeyPem: String): RSAPrivateKey { - val isPkcs1 = privateKeyPem.contains("BEGIN RSA PRIVATE KEY") - val keyBytes = privateKeyPem + val pemText = normalizePemText(privateKeyPem) + val isPkcs1 = pemText.contains("BEGIN RSA PRIVATE KEY") + val keyBytes = pemText .replace("-----BEGIN RSA PRIVATE KEY-----", "") .replace("-----END RSA PRIVATE KEY-----", "") .replace("-----BEGIN PRIVATE KEY-----", "") @@ -130,6 +131,21 @@ class ServiceAccountSigner(privateKey: PrivateKey, private val privateKeyId: Str return key } + private fun normalizePemText(privateKeyPem: String): String { + val trimmed = privateKeyPem.trim() + if (trimmed.contains("BEGIN")) { + return trimmed + } + + return try { + val decoded = Base64.getDecoder().decode(trimmed) + val decodedText = decoded.toString(Charsets.UTF_8).trim() + if (decodedText.contains("BEGIN")) decodedText else trimmed + } catch (_: IllegalArgumentException) { + trimmed + } + } + private fun jsonString(value: String): String { return JsonHelper.moshi().adapter(String::class.java).toJson(value) } diff --git a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt index 47b77da9..c9e03c26 100644 --- a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt @@ -10,7 +10,7 @@ data class UpdateApplicationRequest( * Branding details for the application. */ @Json(name = "branding") - val branding: ApplicationDetails.Branding? = null, + val branding: Branding? = null, /** * Hosted authentication branding details. */ @@ -26,7 +26,7 @@ data class UpdateApplicationRequest( * Builder for [UpdateApplicationRequest]. */ class Builder { - private var branding: ApplicationDetails.Branding? = null + private var branding: Branding? = null private var hostedAuthentication: ApplicationDetails.HostedAuthentication? = null private var callbackUris: List? = null @@ -35,7 +35,7 @@ data class UpdateApplicationRequest( * @param branding Branding details. * @return This builder. */ - fun branding(branding: ApplicationDetails.Branding?) = apply { this.branding = branding } + fun branding(branding: Branding?) = apply { this.branding = branding } /** * Set hosted authentication branding details. @@ -58,4 +58,31 @@ data class UpdateApplicationRequest( */ fun build() = UpdateApplicationRequest(branding, hostedAuthentication, callbackUris) } + + /** + * Branding fields accepted by application updates. All fields are optional + * because PATCH /v3/applications accepts sparse branding changes. + */ + data class Branding( + /** + * Name of the application. + */ + @Json(name = "name") + val name: String? = null, + /** + * URL points to application icon. + */ + @Json(name = "icon_url") + val iconUrl: String? = null, + /** + * Application / publisher website URL. + */ + @Json(name = "website_url") + val websiteUrl: String? = null, + /** + * Description of the application. + */ + @Json(name = "description") + val description: String? = null, + ) } diff --git a/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt b/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt index 5dbf5a8e..3ba6407c 100644 --- a/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt +++ b/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt @@ -25,7 +25,7 @@ class ServiceAccountSignerTests { @Test fun `canonical json uses sdk json names for request models`() { - val json = ServiceAccountSigner.canonicalJson(DomainVerificationRequest(DomainVerificationType.SPF)) + val json = ServiceAccountSigner.canonicalJson(DomainVerificationRequest(DomainVerificationRequestType.SPF)) assertEquals("""{"type":"spf"}""", json) } @@ -57,6 +57,20 @@ class ServiceAccountSignerTests { assertTrue(result.headers.getValue("X-Nylas-Signature").isNotBlank()) } + @Test + fun `loads base64 encoded PEM service account private key`() { + val keyPair = testKeyPair() + val privateKeyBody = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded) + val pem = "-----BEGIN PRIVATE KEY-----\n$privateKeyBody\n-----END PRIVATE KEY-----" + val base64EncodedPem = Base64.getEncoder().encodeToString(pem.toByteArray(Charsets.UTF_8)) + val signer = ServiceAccountSigner(base64EncodedPem, "kid") + + val result = signer.buildHeaders("GET", "/v3/admin/domains", timestamp = 1, nonce = "n".repeat(20)) + + assertEquals("kid", result.headers["X-Nylas-Kid"]) + assertTrue(result.headers.getValue("X-Nylas-Signature").isNotBlank()) + } + private fun signingEnvelope(result: ServiceAccountSigningResult, path: String, method: String): ByteArray { val envelope = linkedMapOf( "method" to method, diff --git a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt index f35925b5..eadaf386 100644 --- a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt @@ -138,6 +138,18 @@ class ApplicationsTests { assert(json.contains("\"url\":\"https://example.com/callback\"")) { "Expected callback URL in JSON, got: $json" } assert(json.contains("\"platform\":\"web\"")) { "Expected platform in JSON, got: $json" } } + + @Test + fun `UpdateApplicationRequest serializes sparse branding fields`() { + val adapter = JsonHelper.moshi().adapter(UpdateApplicationRequest::class.java) + val request = UpdateApplicationRequest.Builder() + .branding(UpdateApplicationRequest.Branding(iconUrl = "https://example.com/icon.png")) + .build() + + val json = adapter.toJson(request) + + assertEquals("""{"branding":{"icon_url":"https://example.com/icon.png"}}""", json) + } } @Nested @@ -174,7 +186,7 @@ class ApplicationsTests { @Test fun `updating application details calls requests with the correct params`() { val requestBody = UpdateApplicationRequest( - branding = ApplicationDetails.Branding(name = "Renamed app"), + branding = UpdateApplicationRequest.Branding(name = "Renamed app"), ) val adapter = JsonHelper.moshi().adapter(UpdateApplicationRequest::class.java) diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index f065668b..1c2d64a1 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -570,7 +570,7 @@ class DomainsTests { @Test fun `getting managed domain info calls requests with the correct params`() { - val requestBody = DomainVerificationRequest(DomainVerificationType.SPF) + val requestBody = DomainVerificationRequest(DomainVerificationRequestType.SPF) val overrides = serviceAccountOverrides() domains.info("dom-123", requestBody, overrides) @@ -595,7 +595,7 @@ class DomainsTests { @Test fun `verifying a managed domain calls requests with the correct params`() { - val requestBody = DomainVerificationRequest(DomainVerificationType.DKIM) + val requestBody = DomainVerificationRequest(DomainVerificationRequestType.DKIM) val overrides = serviceAccountOverrides() domains.verify("dom-123", requestBody, overrides) From 13f4293c8fbb09446b8f36856b4dfd358d473bb9 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:12:32 -0400 Subject: [PATCH 13/23] Make workspace policy detach explicit --- CHANGELOG.md | 2 +- .../com/nylas/models/UpdateWorkspaceRequest.kt | 4 +--- .../kotlin/com/nylas/resources/WorkspacesTests.kt | 12 ------------ 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ef4ed3..3da759d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Application updates support sparse branding fields and `callback_uris` - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth, canonical signed wire bodies, manually signed headers in `RequestOverrides.headers`, base64-encoded PEM service-account keys, and request-only verification types - - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true + - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, explicit `clearPolicyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` - `NylasClient.domains()` accessor returning the new `Domains` resource diff --git a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt index c426e381..5a85dc2a 100644 --- a/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt @@ -23,9 +23,7 @@ data class UpdateWorkspaceRequest( fun name(name: String?) = apply { this.name = name } fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } - fun policyId(policyId: String?) = apply { - this.policyId = if (policyId == null) NullableField.Clear else NullableField.Value(policyId) - } + fun policyId(policyId: String) = apply { this.policyId = NullableField.Value(policyId) } fun clearPolicyId() = apply { this.policyId = NullableField.Clear } fun ruleIds(ruleIds: List?) = apply { this.ruleIds = ruleIds } fun build() = UpdateWorkspaceRequest(name, autoGroup, policyId, ruleIds) diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt index 8d48157c..395a8b0e 100644 --- a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -118,18 +118,6 @@ class WorkspacesTests { assert(!json.contains("\"policy_id\"")) { "Expected policy_id to be omitted, got: $json" } } - @Test - fun `UpdateWorkspaceRequest serializes explicit null policy detach`() { - val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) - val request = UpdateWorkspaceRequest.Builder() - .policyId(null) - .build() - - val json = adapter.toJson(request) - - assert(json.contains("\"policy_id\":null")) { "Expected policy_id:null in JSON, got: $json" } - } - @Test fun `UpdateWorkspaceRequest clearPolicyId serializes explicit null policy detach`() { val adapter = JsonHelper.moshi().adapter(UpdateWorkspaceRequest::class.java) From 7f231059ab051c7057d3a3b510b58109cbbe894a Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:18:39 -0400 Subject: [PATCH 14/23] Unwrap nested rules list response --- src/main/kotlin/com/nylas/models/Rule.kt | 39 ++++++++++++++ src/main/kotlin/com/nylas/resources/Rules.kt | 4 +- .../kotlin/com/nylas/resources/RulesTests.kt | 51 +++++++++++++++---- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/Rule.kt b/src/main/kotlin/com/nylas/models/Rule.kt index 4ac8752e..ba181088 100644 --- a/src/main/kotlin/com/nylas/models/Rule.kt +++ b/src/main/kotlin/com/nylas/models/Rule.kt @@ -67,3 +67,42 @@ data class Rule( @Json(name = "updated_at") val updatedAt: Long = 0, ) + +/** + * Class representation of the nested list envelope returned by GET /v3/rules. + */ +data class RulesListResponse( + /** + * Nested list payload. + */ + @Json(name = "data") + val data: RulesListData = RulesListData(), + /** + * The request ID. + */ + @Json(name = "request_id") + val requestId: String = "", +) { + /** + * Convert the nested rules list envelope into the SDK's standard list response. + */ + fun toListResponse(): ListResponse { + return ListResponse(data = data.items, requestId = requestId, nextCursor = data.nextCursor) + } +} + +/** + * Class representation of the nested rules list data payload. + */ +data class RulesListData( + /** + * Rules returned by the API. + */ + @Json(name = "items") + val items: List = emptyList(), + /** + * The cursor to use to get the next page of rules. + */ + @Json(name = "next_cursor") + val nextCursor: String? = null, +) diff --git a/src/main/kotlin/com/nylas/resources/Rules.kt b/src/main/kotlin/com/nylas/resources/Rules.kt index 0b20b34c..78119fa1 100644 --- a/src/main/kotlin/com/nylas/resources/Rules.kt +++ b/src/main/kotlin/com/nylas/resources/Rules.kt @@ -24,8 +24,8 @@ class Rules(client: NylasClient) : Resource(client, Rule::class.java) { @Throws(NylasApiError::class, NylasSdkTimeoutError::class) @JvmOverloads fun list(queryParams: ListRulesQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { - val responseType = Types.newParameterizedType(ListResponse::class.java, Rule::class.java) - return client.executeGet("v3/rules", responseType, queryParams, overrides) + val nestedResponse = client.executeGet("v3/rules", RulesListResponse::class.java, queryParams, overrides) + return nestedResponse.toListResponse() } /** diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index e8e6aff7..831a53ae 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -246,6 +246,39 @@ class RulesTests { assertNull(rule.description) } + @Test + fun `RulesListResponse unwraps nested list envelope`() { + val adapter = JsonHelper.moshi().adapter(RulesListResponse::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "request_id": "request-123", + "data": { + "items": [ + { + "id": "rule-123", + "name": "Block spam", + "actions": [{"type": "block"}], + "application_id": "app-id", + "organization_id": "org-id", + "created_at": 1742932766, + "updated_at": 1742932767 + } + ], + "next_cursor": "cursor-123" + } + } + """.trimIndent(), + ) + + val listResponse = adapter.fromJson(jsonBuffer)!!.toListResponse() + + assertEquals("request-123", listResponse.requestId) + assertEquals("cursor-123", listResponse.nextCursor) + assertEquals(1, listResponse.data.size) + assertEquals("rule-123", listResponse.data[0].id) + } + @Test fun `RuleEvaluation deserializes properly`() { val adapter = JsonHelper.moshi().adapter(RuleEvaluation::class.java) @@ -298,10 +331,9 @@ class RulesTests { @Test fun `listing rules calls requests with the correct params`() { - val responseType = Types.newParameterizedType(ListResponse::class.java, Rule::class.java) whenever( - mockNylasClient.executeGet>(any(), any(), anyOrNull(), anyOrNull()), - ).thenReturn(ListResponse()) + mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), + ).thenReturn(RulesListResponse()) rules.list() @@ -309,7 +341,7 @@ class RulesTests { val typeCaptor = argumentCaptor() val queryParamCaptor = argumentCaptor() val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet>( + verify(mockNylasClient).executeGet( pathCaptor.capture(), typeCaptor.capture(), queryParamCaptor.capture(), @@ -317,17 +349,16 @@ class RulesTests { ) assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(responseType, typeCaptor.firstValue) + assertEquals(RulesListResponse::class.java, typeCaptor.firstValue) assertNull(queryParamCaptor.firstValue) } @Test fun `listing rules with query params passes them correctly`() { val queryParams = ListRulesQueryParams(limit = 5, pageToken = "cursor123") - val responseType = Types.newParameterizedType(ListResponse::class.java, Rule::class.java) whenever( - mockNylasClient.executeGet>(any(), any(), anyOrNull(), anyOrNull()), - ).thenReturn(ListResponse()) + mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), + ).thenReturn(RulesListResponse()) rules.list(queryParams) @@ -335,7 +366,7 @@ class RulesTests { val typeCaptor = argumentCaptor() val queryParamCaptor = argumentCaptor() val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet>( + verify(mockNylasClient).executeGet( pathCaptor.capture(), typeCaptor.capture(), queryParamCaptor.capture(), @@ -343,7 +374,7 @@ class RulesTests { ) assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(responseType, typeCaptor.firstValue) + assertEquals(RulesListResponse::class.java, typeCaptor.firstValue) assertEquals(queryParams, queryParamCaptor.firstValue) } From 2f155cc08ddc0299f8b5e6b6bdb6ddcd9e394b99 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:24:56 -0400 Subject: [PATCH 15/23] Support flat and nested rules list responses --- src/main/kotlin/com/nylas/models/Rule.kt | 46 +++++++++++-------- .../kotlin/com/nylas/resources/RulesTests.kt | 31 +++++++++++++ 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/Rule.kt b/src/main/kotlin/com/nylas/models/Rule.kt index ba181088..b95bc8ec 100644 --- a/src/main/kotlin/com/nylas/models/Rule.kt +++ b/src/main/kotlin/com/nylas/models/Rule.kt @@ -1,5 +1,6 @@ package com.nylas.models +import com.nylas.util.JsonHelper import com.squareup.moshi.Json /** @@ -73,36 +74,41 @@ data class Rule( */ data class RulesListResponse( /** - * Nested list payload. + * Rules list payload. The API has returned both a standard list array and + * a nested object with `items` and `next_cursor`; normalize either shape. */ @Json(name = "data") - val data: RulesListData = RulesListData(), + val data: Any? = emptyList(), /** * The request ID. */ @Json(name = "request_id") val requestId: String = "", + /** + * The cursor to use to get the next page of rules when returned at the top level. + */ + @Json(name = "next_cursor") + val nextCursor: String? = null, ) { /** - * Convert the nested rules list envelope into the SDK's standard list response. + * Convert the rules list envelope into the SDK's standard list response. */ fun toListResponse(): ListResponse { - return ListResponse(data = data.items, requestId = requestId, nextCursor = data.nextCursor) + val nestedData = data as? Map<*, *> + val rawItems = when (data) { + is List<*> -> data + is Map<*, *> -> data["items"] as? List<*> ?: emptyList() + else -> emptyList() + } + val rules = rawItems.mapNotNull { ruleAdapter.fromJsonValue(it) } + return ListResponse( + data = rules, + requestId = requestId, + nextCursor = nextCursor ?: (nestedData?.get("next_cursor") as? String), + ) } -} -/** - * Class representation of the nested rules list data payload. - */ -data class RulesListData( - /** - * Rules returned by the API. - */ - @Json(name = "items") - val items: List = emptyList(), - /** - * The cursor to use to get the next page of rules. - */ - @Json(name = "next_cursor") - val nextCursor: String? = null, -) + companion object { + private val ruleAdapter = JsonHelper.moshi().adapter(Rule::class.java) + } +} diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index 831a53ae..ef7f85fe 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -279,6 +279,37 @@ class RulesTests { assertEquals("rule-123", listResponse.data[0].id) } + @Test + fun `RulesListResponse unwraps flat list envelope`() { + val adapter = JsonHelper.moshi().adapter(RulesListResponse::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "request_id": "request-123", + "data": [ + { + "id": "rule-123", + "name": "Block spam", + "actions": [{"type": "block"}], + "application_id": "app-id", + "organization_id": "org-id", + "created_at": 1742932766, + "updated_at": 1742932767 + } + ], + "next_cursor": "cursor-123" + } + """.trimIndent(), + ) + + val listResponse = adapter.fromJson(jsonBuffer)!!.toListResponse() + + assertEquals("request-123", listResponse.requestId) + assertEquals("cursor-123", listResponse.nextCursor) + assertEquals(1, listResponse.data.size) + assertEquals("rule-123", listResponse.data[0].id) + } + @Test fun `RuleEvaluation deserializes properly`() { val adapter = JsonHelper.moshi().adapter(RuleEvaluation::class.java) From 7f3f68e5c18280958ef32a2e2169f78d7f9ab4f6 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:36:01 -0400 Subject: [PATCH 16/23] Address Java list and callback URI feedback --- .../com/nylas/models/CreateNylasListRequest.kt | 8 +------- .../com/nylas/models/UpdateApplicationRequest.kt | 6 +++--- .../com/nylas/resources/ApplicationsTests.kt | 4 ++-- .../kotlin/com/nylas/resources/NylasListsTests.kt | 15 --------------- 4 files changed, 6 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt b/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt index 7fab5454..72b21912 100644 --- a/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt @@ -7,7 +7,7 @@ import com.squareup.moshi.Json */ data class CreateNylasListRequest( /** - * Name of the list (1–256 characters). + * Name of the list. */ @Json(name = "name") val name: String, @@ -22,12 +22,6 @@ data class CreateNylasListRequest( @Json(name = "description") val description: String? = null, ) { - init { - require(name.isNotBlank() && name.length <= 256) { - "name must be between 1 and 256 characters" - } - } - /** * Builder for [CreateNylasListRequest]. * @param name Name of the list. diff --git a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt index c9e03c26..9a3ee6dc 100644 --- a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt @@ -20,7 +20,7 @@ data class UpdateApplicationRequest( * List of callback URIs for the application. */ @Json(name = "callback_uris") - val callbackUris: List? = null, + val callbackUris: List? = null, ) { /** * Builder for [UpdateApplicationRequest]. @@ -28,7 +28,7 @@ data class UpdateApplicationRequest( class Builder { private var branding: Branding? = null private var hostedAuthentication: ApplicationDetails.HostedAuthentication? = null - private var callbackUris: List? = null + private var callbackUris: List? = null /** * Set branding details for the application. @@ -50,7 +50,7 @@ data class UpdateApplicationRequest( * @param callbackUris List of callback URIs. * @return This builder. */ - fun callbackUris(callbackUris: List?) = apply { this.callbackUris = callbackUris } + fun callbackUris(callbackUris: List?) = apply { this.callbackUris = callbackUris } /** * Build the [UpdateApplicationRequest]. diff --git a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt index eadaf386..86d4b622 100644 --- a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt @@ -123,8 +123,7 @@ class ApplicationsTests { val request = UpdateApplicationRequest.Builder() .callbackUris( listOf( - RedirectUri( - id = "0556d035-6cb6-4262-a035-6b77e11cf8fc", + CreateRedirectUriRequest( url = "https://example.com/callback", platform = Platform.WEB, ), @@ -137,6 +136,7 @@ class ApplicationsTests { assert(json.contains("\"callback_uris\"")) { "Expected callback_uris in JSON, got: $json" } assert(json.contains("\"url\":\"https://example.com/callback\"")) { "Expected callback URL in JSON, got: $json" } assert(json.contains("\"platform\":\"web\"")) { "Expected platform in JSON, got: $json" } + assert(!json.contains("\"id\"")) { "Expected callback URI id to be omitted from JSON, got: $json" } } @Test diff --git a/src/test/kotlin/com/nylas/resources/NylasListsTests.kt b/src/test/kotlin/com/nylas/resources/NylasListsTests.kt index 92325d16..e080c6a5 100644 --- a/src/test/kotlin/com/nylas/resources/NylasListsTests.kt +++ b/src/test/kotlin/com/nylas/resources/NylasListsTests.kt @@ -21,7 +21,6 @@ import org.mockito.kotlin.whenever import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -137,20 +136,6 @@ class NylasListsTests { assertEquals("Top-level domains to block", request.description) } - @Test - fun `CreateNylasListRequest rejects blank name`() { - assertFailsWith { - CreateNylasListRequest(name = " ", type = NylasListType.DOMAIN) - } - } - - @Test - fun `CreateNylasListRequest rejects names over 256 characters`() { - assertFailsWith { - CreateNylasListRequest(name = "a".repeat(257), type = NylasListType.DOMAIN) - } - } - @Test fun `ListItemsRequest serializes correctly`() { val adapter = JsonHelper.moshi().adapter(ListItemsRequest::class.java) From 6047079e6aaa3fe3bccb3e75d0c3bf7669ddf49e Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:40:52 -0400 Subject: [PATCH 17/23] Complete Java domains parity fixes --- .../com/nylas/models/DomainVerification.kt | 6 ++++++ .../com/nylas/models/ListDomainsQueryParams.kt | 10 +++++++++- .../kotlin/com/nylas/resources/DomainsTests.kt | 17 +++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/DomainVerification.kt b/src/main/kotlin/com/nylas/models/DomainVerification.kt index a1042b5c..afcb9dc1 100644 --- a/src/main/kotlin/com/nylas/models/DomainVerification.kt +++ b/src/main/kotlin/com/nylas/models/DomainVerification.kt @@ -46,6 +46,12 @@ enum class DomainVerificationRequestType { @Json(name = "feedback") FEEDBACK, + + @Json(name = "dmarc") + DMARC, + + @Json(name = "arc") + ARC, } /** diff --git a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt index 43ee4a80..ebbb82df 100644 --- a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt @@ -10,13 +10,21 @@ data class ListDomainsQueryParams( val limit: Int? = null, @Json(name = "page_token") val pageToken: String? = null, + @Json(name = "domain") + val domain: String? = null, + @Json(name = "region") + val region: Region? = null, ) : IQueryParams { class Builder { private var limit: Int? = null private var pageToken: String? = null + private var domain: String? = null + private var region: Region? = null fun limit(limit: Int?) = apply { this.limit = limit } fun pageToken(pageToken: String?) = apply { this.pageToken = pageToken } - fun build() = ListDomainsQueryParams(limit, pageToken) + fun domain(domain: String?) = apply { this.domain = domain } + fun region(region: Region?) = apply { this.region = region } + fun build() = ListDomainsQueryParams(limit, pageToken, domain, region) } } diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index 1c2d64a1..a0dd2b77 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -184,9 +184,22 @@ class DomainsTests { val queryParams = ListDomainsQueryParams.Builder() .limit(5) .pageToken("cursor123") + .domain("mail.acme.com") + .region(Region.US) .build() - assertEquals(mapOf("limit" to 5.0, "page_token" to "cursor123"), queryParams.convertToMap()) + assertEquals( + mapOf("limit" to 5.0, "page_token" to "cursor123", "domain" to "mail.acme.com", "region" to "us"), + queryParams.convertToMap(), + ) + } + + @Test + fun `DomainVerificationRequest supports all public verification request types`() { + val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) + + assertEquals("""{"type":"dmarc"}""", adapter.toJson(DomainVerificationRequest(DomainVerificationRequestType.DMARC))) + assertEquals("""{"type":"arc"}""", adapter.toJson(DomainVerificationRequest(DomainVerificationRequestType.ARC))) } } @@ -354,7 +367,7 @@ class DomainsTests { @Test fun `listing managed domains calls requests with the correct params`() { - val queryParams = ListDomainsQueryParams(limit = 5, pageToken = "cursor123") + val queryParams = ListDomainsQueryParams(limit = 5, pageToken = "cursor123", domain = "mail.acme.com", region = Region.US) val overrides = serviceAccountOverrides() domains.list(queryParams, overrides) From 2869c28c00f6097d9a1cc728b23020d80c9a4de6 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:48:45 -0400 Subject: [PATCH 18/23] Split domain verification request types --- .../com/nylas/models/DomainVerification.kt | 6 ------ .../kotlin/com/nylas/resources/DomainsTests.kt | 17 +++++++++-------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/DomainVerification.kt b/src/main/kotlin/com/nylas/models/DomainVerification.kt index afcb9dc1..a1042b5c 100644 --- a/src/main/kotlin/com/nylas/models/DomainVerification.kt +++ b/src/main/kotlin/com/nylas/models/DomainVerification.kt @@ -46,12 +46,6 @@ enum class DomainVerificationRequestType { @Json(name = "feedback") FEEDBACK, - - @Json(name = "dmarc") - DMARC, - - @Json(name = "arc") - ARC, } /** diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index a0dd2b77..912140ae 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -179,6 +179,15 @@ class DomainsTests { assertEquals("""{"name":"Renamed"}""", json) } + @Test + fun `DomainVerificationResult deserializes extended response verification types`() { + val adapter = JsonHelper.moshi().adapter(DomainVerificationResult::class.java) + val result = adapter.fromJson("""{"domain_id":"dom-123","attempt":{"type":"dmarc"},"status":"pending"}""")!! + + assertEquals(DomainVerificationType.DMARC, result.attempt?.type) + assertEquals(DomainVerificationStatus.PENDING, result.status) + } + @Test fun `ListDomainsQueryParams only exposes documented query parameters`() { val queryParams = ListDomainsQueryParams.Builder() @@ -193,14 +202,6 @@ class DomainsTests { queryParams.convertToMap(), ) } - - @Test - fun `DomainVerificationRequest supports all public verification request types`() { - val adapter = JsonHelper.moshi().adapter(DomainVerificationRequest::class.java) - - assertEquals("""{"type":"dmarc"}""", adapter.toJson(DomainVerificationRequest(DomainVerificationRequestType.DMARC))) - assertEquals("""{"type":"arc"}""", adapter.toJson(DomainVerificationRequest(DomainVerificationRequestType.ARC))) - } } @Nested From 999315c904ce9bfcc0b1238e85b8b01ab5662aec Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:56:04 -0400 Subject: [PATCH 19/23] Keep rules list wrapper internal --- src/main/kotlin/com/nylas/models/Rule.kt | 45 ----- src/main/kotlin/com/nylas/resources/Rules.kt | 34 ++++ .../kotlin/com/nylas/resources/RulesTests.kt | 167 +++++++----------- 3 files changed, 101 insertions(+), 145 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/Rule.kt b/src/main/kotlin/com/nylas/models/Rule.kt index b95bc8ec..4ac8752e 100644 --- a/src/main/kotlin/com/nylas/models/Rule.kt +++ b/src/main/kotlin/com/nylas/models/Rule.kt @@ -1,6 +1,5 @@ package com.nylas.models -import com.nylas.util.JsonHelper import com.squareup.moshi.Json /** @@ -68,47 +67,3 @@ data class Rule( @Json(name = "updated_at") val updatedAt: Long = 0, ) - -/** - * Class representation of the nested list envelope returned by GET /v3/rules. - */ -data class RulesListResponse( - /** - * Rules list payload. The API has returned both a standard list array and - * a nested object with `items` and `next_cursor`; normalize either shape. - */ - @Json(name = "data") - val data: Any? = emptyList(), - /** - * The request ID. - */ - @Json(name = "request_id") - val requestId: String = "", - /** - * The cursor to use to get the next page of rules when returned at the top level. - */ - @Json(name = "next_cursor") - val nextCursor: String? = null, -) { - /** - * Convert the rules list envelope into the SDK's standard list response. - */ - fun toListResponse(): ListResponse { - val nestedData = data as? Map<*, *> - val rawItems = when (data) { - is List<*> -> data - is Map<*, *> -> data["items"] as? List<*> ?: emptyList() - else -> emptyList() - } - val rules = rawItems.mapNotNull { ruleAdapter.fromJsonValue(it) } - return ListResponse( - data = rules, - requestId = requestId, - nextCursor = nextCursor ?: (nestedData?.get("next_cursor") as? String), - ) - } - - companion object { - private val ruleAdapter = JsonHelper.moshi().adapter(Rule::class.java) - } -} diff --git a/src/main/kotlin/com/nylas/resources/Rules.kt b/src/main/kotlin/com/nylas/resources/Rules.kt index 78119fa1..df4ef0dc 100644 --- a/src/main/kotlin/com/nylas/resources/Rules.kt +++ b/src/main/kotlin/com/nylas/resources/Rules.kt @@ -4,6 +4,7 @@ import com.nylas.NylasClient import com.nylas.models.* import com.nylas.util.JsonHelper import com.nylas.util.PathEncoder +import com.squareup.moshi.Json import com.squareup.moshi.Types /** @@ -101,3 +102,36 @@ class Rules(client: NylasClient) : Resource(client, Rule::class.java) { return client.executeGet(path, responseType, queryParams, overrides) } } + +/** + * Internal compatibility envelope for GET /v3/rules responses. The API has + * returned both a standard list array and a nested object with `items` and + * `next_cursor`; normalize either shape before returning ListResponse. + */ +private data class RulesListResponse( + @Json(name = "data") + val data: Any? = emptyList(), + @Json(name = "request_id") + val requestId: String = "", + @Json(name = "next_cursor") + val nextCursor: String? = null, +) { + fun toListResponse(): ListResponse { + val nestedData = data as? Map<*, *> + val rawItems = when (data) { + is List<*> -> data + is Map<*, *> -> data["items"] as? List<*> ?: emptyList() + else -> emptyList() + } + val rules = rawItems.mapNotNull { ruleAdapter.fromJsonValue(it) } + return ListResponse( + data = rules, + requestId = requestId, + nextCursor = nextCursor ?: (nestedData?.get("next_cursor") as? String), + ) + } + + companion object { + private val ruleAdapter = JsonHelper.moshi().adapter(Rule::class.java) + } +} diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index ef7f85fe..6fa97284 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -7,19 +7,22 @@ import com.nylas.util.JsonHelper import com.squareup.moshi.Types import okhttp3.Call import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.Protocol import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import okio.Buffer import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.lang.reflect.Type +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -246,70 +249,6 @@ class RulesTests { assertNull(rule.description) } - @Test - fun `RulesListResponse unwraps nested list envelope`() { - val adapter = JsonHelper.moshi().adapter(RulesListResponse::class.java) - val jsonBuffer = Buffer().writeUtf8( - """ - { - "request_id": "request-123", - "data": { - "items": [ - { - "id": "rule-123", - "name": "Block spam", - "actions": [{"type": "block"}], - "application_id": "app-id", - "organization_id": "org-id", - "created_at": 1742932766, - "updated_at": 1742932767 - } - ], - "next_cursor": "cursor-123" - } - } - """.trimIndent(), - ) - - val listResponse = adapter.fromJson(jsonBuffer)!!.toListResponse() - - assertEquals("request-123", listResponse.requestId) - assertEquals("cursor-123", listResponse.nextCursor) - assertEquals(1, listResponse.data.size) - assertEquals("rule-123", listResponse.data[0].id) - } - - @Test - fun `RulesListResponse unwraps flat list envelope`() { - val adapter = JsonHelper.moshi().adapter(RulesListResponse::class.java) - val jsonBuffer = Buffer().writeUtf8( - """ - { - "request_id": "request-123", - "data": [ - { - "id": "rule-123", - "name": "Block spam", - "actions": [{"type": "block"}], - "application_id": "app-id", - "organization_id": "org-id", - "created_at": 1742932766, - "updated_at": 1742932767 - } - ], - "next_cursor": "cursor-123" - } - """.trimIndent(), - ) - - val listResponse = adapter.fromJson(jsonBuffer)!!.toListResponse() - - assertEquals("request-123", listResponse.requestId) - assertEquals("cursor-123", listResponse.nextCursor) - assertEquals(1, listResponse.data.size) - assertEquals("rule-123", listResponse.data[0].id) - } - @Test fun `RuleEvaluation deserializes properly`() { val adapter = JsonHelper.moshi().adapter(RuleEvaluation::class.java) @@ -362,51 +301,44 @@ class RulesTests { @Test fun `listing rules calls requests with the correct params`() { - whenever( - mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), - ).thenReturn(RulesListResponse()) + val capturedRequest = AtomicReference() + val client = NylasClient("api-key", rulesListClient(capturedRequest, rulesListResponse(flat = true)), "https://api.test.nylas.com/") - rules.list() - - val pathCaptor = argumentCaptor() - val typeCaptor = argumentCaptor() - val queryParamCaptor = argumentCaptor() - val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet( - pathCaptor.capture(), - typeCaptor.capture(), - queryParamCaptor.capture(), - overrideParamCaptor.capture(), - ) + val response = client.rules().list() - assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(RulesListResponse::class.java, typeCaptor.firstValue) - assertNull(queryParamCaptor.firstValue) + assertEquals("/v3/rules", capturedRequest.get().url.encodedPath) + assertEquals("request-123", response.requestId) + assertEquals("cursor-123", response.nextCursor) + assertEquals(1, response.data.size) + assertEquals("rule-123", response.data[0].id) } @Test fun `listing rules with query params passes them correctly`() { val queryParams = ListRulesQueryParams(limit = 5, pageToken = "cursor123") - whenever( - mockNylasClient.executeGet(any(), any(), anyOrNull(), anyOrNull()), - ).thenReturn(RulesListResponse()) + val capturedRequest = AtomicReference() + val client = NylasClient("api-key", rulesListClient(capturedRequest, rulesListResponse(flat = true)), "https://api.test.nylas.com/") - rules.list(queryParams) + client.rules().list(queryParams) - val pathCaptor = argumentCaptor() - val typeCaptor = argumentCaptor() - val queryParamCaptor = argumentCaptor() - val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet( - pathCaptor.capture(), - typeCaptor.capture(), - queryParamCaptor.capture(), - overrideParamCaptor.capture(), - ) + val requestUrl = capturedRequest.get().url + assertEquals("/v3/rules", requestUrl.encodedPath) + assertEquals("5", requestUrl.queryParameter("limit")) + assertEquals("cursor123", requestUrl.queryParameter("page_token")) + } - assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(RulesListResponse::class.java, typeCaptor.firstValue) - assertEquals(queryParams, queryParamCaptor.firstValue) + @Test + fun `listing rules unwraps nested list envelope`() { + val capturedRequest = AtomicReference() + val client = NylasClient("api-key", rulesListClient(capturedRequest, rulesListResponse(flat = false)), "https://api.test.nylas.com/") + + val response = client.rules().list() + + assertEquals("/v3/rules", capturedRequest.get().url.encodedPath) + assertEquals("request-123", response.requestId) + assertEquals("cursor-123", response.nextCursor) + assertEquals(1, response.data.size) + assertEquals("rule-123", response.data[0].id) } @Test @@ -528,5 +460,40 @@ class RulesTests { assertEquals(Types.newParameterizedType(ListResponse::class.java, RuleEvaluation::class.java), typeCaptor.firstValue) assertEquals(queryParams, queryParamCaptor.firstValue) } + + private fun rulesListClient(capturedRequest: AtomicReference, responseBody: String): OkHttpClient.Builder { + return OkHttpClient.Builder().addInterceptor { chain -> + val request = chain.request() + capturedRequest.set(request) + okhttp3.Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody.toResponseBody("application/json".toMediaType())) + .build() + } + } + + private fun rulesListResponse(flat: Boolean): String { + val ruleJson = """ + { + "id": "rule-123", + "name": "Block spam", + "actions": [{"type": "block"}], + "application_id": "app-id", + "organization_id": "org-id", + "created_at": 1742932766, + "updated_at": 1742932767 + } + """.trimIndent() + val dataJson = if (flat) { + """[$ruleJson]""" + } else { + """{"items":[$ruleJson],"next_cursor":"cursor-123"}""" + } + val topLevelCursor = if (flat) ""","next_cursor":"cursor-123"""" else "" + return """{"request_id":"request-123","data":$dataJson$topLevelCursor}""" + } } } From 0e5ae0390406eab916efac49c9e4e27c88ea5eeb Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 12:04:17 -0400 Subject: [PATCH 20/23] Remove unsupported domain list filters --- .../kotlin/com/nylas/models/ListDomainsQueryParams.kt | 10 +--------- src/test/kotlin/com/nylas/resources/DomainsTests.kt | 6 ++---- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt index ebbb82df..43ee4a80 100644 --- a/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt @@ -10,21 +10,13 @@ data class ListDomainsQueryParams( val limit: Int? = null, @Json(name = "page_token") val pageToken: String? = null, - @Json(name = "domain") - val domain: String? = null, - @Json(name = "region") - val region: Region? = null, ) : IQueryParams { class Builder { private var limit: Int? = null private var pageToken: String? = null - private var domain: String? = null - private var region: Region? = null fun limit(limit: Int?) = apply { this.limit = limit } fun pageToken(pageToken: String?) = apply { this.pageToken = pageToken } - fun domain(domain: String?) = apply { this.domain = domain } - fun region(region: Region?) = apply { this.region = region } - fun build() = ListDomainsQueryParams(limit, pageToken, domain, region) + fun build() = ListDomainsQueryParams(limit, pageToken) } } diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index 912140ae..93bd1392 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -193,12 +193,10 @@ class DomainsTests { val queryParams = ListDomainsQueryParams.Builder() .limit(5) .pageToken("cursor123") - .domain("mail.acme.com") - .region(Region.US) .build() assertEquals( - mapOf("limit" to 5.0, "page_token" to "cursor123", "domain" to "mail.acme.com", "region" to "us"), + mapOf("limit" to 5.0, "page_token" to "cursor123"), queryParams.convertToMap(), ) } @@ -368,7 +366,7 @@ class DomainsTests { @Test fun `listing managed domains calls requests with the correct params`() { - val queryParams = ListDomainsQueryParams(limit = 5, pageToken = "cursor123", domain = "mail.acme.com", region = Region.US) + val queryParams = ListDomainsQueryParams(limit = 5, pageToken = "cursor123") val overrides = serviceAccountOverrides() domains.list(queryParams, overrides) From cd53b183821abe8ebace7b232e8ab5bd7e2c5c90 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 12:14:04 -0400 Subject: [PATCH 21/23] Restore RequestOverrides JVM constructor --- .../com/nylas/models/RequestOverrides.kt | 11 +++++++ .../com/nylas/models/RequestOverridesTests.kt | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/test/kotlin/com/nylas/models/RequestOverridesTests.kt diff --git a/src/main/kotlin/com/nylas/models/RequestOverrides.kt b/src/main/kotlin/com/nylas/models/RequestOverrides.kt index f52a6d46..566da9b1 100644 --- a/src/main/kotlin/com/nylas/models/RequestOverrides.kt +++ b/src/main/kotlin/com/nylas/models/RequestOverrides.kt @@ -26,6 +26,17 @@ data class RequestOverrides( */ val omitAuthorization: Boolean = false, ) { + /** + * Preserve the public JVM constructor that existed before internal auth-control + * support was added. + */ + constructor( + apiKey: String?, + apiUri: String?, + timeout: Long?, + headers: Map?, + ) : this(apiKey, apiUri, timeout, headers, false) + /** * Builder for [RequestOverrides]. */ diff --git a/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt b/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt new file mode 100644 index 00000000..53a2feda --- /dev/null +++ b/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt @@ -0,0 +1,29 @@ +package com.nylas.models + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RequestOverridesTests { + @Test + fun `four argument JVM constructor remains available`() { + val constructor = RequestOverrides::class.java.getConstructor( + String::class.java, + String::class.java, + java.lang.Long::class.java, + Map::class.java, + ) + + val overrides = constructor.newInstance( + "api-key", + "https://api.test.nylas.com", + 30L, + mapOf("X-Test" to "true"), + ) + + assertEquals("api-key", overrides.apiKey) + assertEquals("https://api.test.nylas.com", overrides.apiUri) + assertEquals(30L, overrides.timeout) + assertEquals(mapOf("X-Test" to "true"), overrides.headers) + assertEquals(false, overrides.omitAuthorization) + } +} From ee9b08883a7d9f79aa62e394dac8070c691aae29 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 12:20:15 -0400 Subject: [PATCH 22/23] Keep RequestOverrides data class ABI stable --- .../kotlin/com/nylas/models/RequestOverrides.kt | 14 ++------------ src/main/kotlin/com/nylas/resources/Domains.kt | 4 ++-- .../com/nylas/models/RequestOverridesTests.kt | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/nylas/models/RequestOverrides.kt b/src/main/kotlin/com/nylas/models/RequestOverrides.kt index 566da9b1..0be28224 100644 --- a/src/main/kotlin/com/nylas/models/RequestOverrides.kt +++ b/src/main/kotlin/com/nylas/models/RequestOverrides.kt @@ -20,22 +20,12 @@ data class RequestOverrides( * Additional headers to include in the request. */ val headers: Map? = emptyMap(), +) { /** * Omit the default Authorization header for requests that use another authentication scheme. * @suppress Not for public use. */ - val omitAuthorization: Boolean = false, -) { - /** - * Preserve the public JVM constructor that existed before internal auth-control - * support was added. - */ - constructor( - apiKey: String?, - apiUri: String?, - timeout: Long?, - headers: Map?, - ) : this(apiKey, apiUri, timeout, headers, false) + internal var omitAuthorization: Boolean = false /** * Builder for [RequestOverrides]. diff --git a/src/main/kotlin/com/nylas/resources/Domains.kt b/src/main/kotlin/com/nylas/resources/Domains.kt index 17e107ca..cd8ad11c 100644 --- a/src/main/kotlin/com/nylas/resources/Domains.kt +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -19,13 +19,13 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja "Manage Domains endpoints require Nylas Service Account signing headers: ${missingHeaders.joinToString()}" } - return (overrides ?: RequestOverrides()).copy(omitAuthorization = true) + return (overrides ?: RequestOverrides()).copy().apply { omitAuthorization = true } } private fun mergeSignerOverrides(overrides: RequestOverrides?, signerHeaders: Map): RequestOverrides { val headers = overrides?.headers.orEmpty().toMutableMap() headers.putAll(signerHeaders) - return (overrides ?: RequestOverrides()).copy(headers = headers, omitAuthorization = true) + return (overrides ?: RequestOverrides()).copy(headers = headers).apply { omitAuthorization = true } } private fun signedRequest( diff --git a/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt b/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt index 53a2feda..08a585ac 100644 --- a/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt +++ b/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt @@ -5,7 +5,7 @@ import kotlin.test.assertEquals class RequestOverridesTests { @Test - fun `four argument JVM constructor remains available`() { + fun `public data class ABI remains four fields`() { val constructor = RequestOverrides::class.java.getConstructor( String::class.java, String::class.java, @@ -25,5 +25,19 @@ class RequestOverridesTests { assertEquals(30L, overrides.timeout) assertEquals(mapOf("X-Test" to "true"), overrides.headers) assertEquals(false, overrides.omitAuthorization) + + RequestOverrides::class.java.getDeclaredMethod( + "copy", + String::class.java, + String::class.java, + java.lang.Long::class.java, + Map::class.java, + ) + assertEquals( + false, + RequestOverrides::class.java.declaredConstructors.any { declaredConstructor -> + declaredConstructor.parameterTypes.lastOrNull() == java.lang.Boolean.TYPE + }, + ) } } From 0a4114af7bfab8ae97014b81d4023c3e5ad481ba Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 13:19:33 -0400 Subject: [PATCH 23/23] Address application and workspace review feedback --- CHANGELOG.md | 4 +-- .../nylas/models/UpdateApplicationRequest.kt | 32 +++++++++++++++++-- .../nylas/models/WorkspaceAutoGroupRequest.kt | 9 ++++++ .../com/nylas/resources/ApplicationsTests.kt | 5 +-- .../com/nylas/resources/WorkspacesTests.kt | 15 +++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da759d5..721847f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ ### Added * Application administration updates - `Applications.update()` for `PATCH /v3/applications` - - Application updates support sparse branding fields and `callback_uris` + - Application updates support sparse branding fields and `callback_uris`, including callback URI IDs for preserving existing callback URIs - Redirect URI updates use `PATCH /v3/applications/redirect-uris/{id}` - Manage Domains admin CRUD and verification endpoints on `client.domains()` via `/v3/admin/domains`; these support `ServiceAccountSigner` for Nylas Service Account request-signing auth without Bearer auth, canonical signed wire bodies, manually signed headers in `RequestOverrides.headers`, base64-encoded PEM service-account keys, and request-only verification types - - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, explicit `clearPolicyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true + - `Workspaces` resource via `client.workspaces()`: CRUD, paginated listing with `limit` and `page_token`, `autoGroup`, `manualAssign`, `default`, `policyId`, explicit `clearPolicyId`, and `ruleIds`; `CreateWorkspaceRequest` validates that `domain` is present when `autoGroup` is true; `WorkspaceAutoGroupRequest.invalidAlso` includes invalid grants in auto-grouping when enabled * Transactional email support via `Domains.sendTransactionalEmail()` - `SendTransactionalEmailRequest` model (and fluent `Builder`) for composing transactional messages from a verified domain — supports `to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `body`, `send_at`, `reply_to_message_id`, `tracking_options`, `use_draft`, `custom_headers`, and `is_plaintext` - `NylasClient.domains()` accessor returning the new `Domains` resource diff --git a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt index 9a3ee6dc..ce8aa021 100644 --- a/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt @@ -20,7 +20,7 @@ data class UpdateApplicationRequest( * List of callback URIs for the application. */ @Json(name = "callback_uris") - val callbackUris: List? = null, + val callbackUris: List? = null, ) { /** * Builder for [UpdateApplicationRequest]. @@ -28,7 +28,7 @@ data class UpdateApplicationRequest( class Builder { private var branding: Branding? = null private var hostedAuthentication: ApplicationDetails.HostedAuthentication? = null - private var callbackUris: List? = null + private var callbackUris: List? = null /** * Set branding details for the application. @@ -50,7 +50,7 @@ data class UpdateApplicationRequest( * @param callbackUris List of callback URIs. * @return This builder. */ - fun callbackUris(callbackUris: List?) = apply { this.callbackUris = callbackUris } + fun callbackUris(callbackUris: List?) = apply { this.callbackUris = callbackUris } /** * Build the [UpdateApplicationRequest]. @@ -86,3 +86,29 @@ data class UpdateApplicationRequest( val description: String? = null, ) } + +/** + * Callback URI shape accepted by application updates. + */ +data class UpdateApplicationRedirectUriRequest( + /** + * Existing callback URI ID. Include this when preserving or updating an existing URI. + */ + @Json(name = "id") + val id: String? = null, + /** + * Redirect URL. + */ + @Json(name = "url") + val url: String, + /** + * Platform identifier. + */ + @Json(name = "platform") + val platform: Platform, + /** + * Optional settings for the redirect URI. + */ + @Json(name = "settings") + val settings: RedirectUriSettings? = null, +) diff --git a/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt index 236cfd48..4a53cd1e 100644 --- a/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt +++ b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt @@ -6,10 +6,19 @@ import com.squareup.moshi.Json * Class representation of a Nylas workspace auto-group request. */ data class WorkspaceAutoGroupRequest( + /** + * Only group grants created at or after this Unix timestamp. + */ @Json(name = "after_created_at") val afterCreatedAt: Long? = null, + /** + * When true, includes invalid grants in the grouping pass. Defaults to false. + */ @Json(name = "invalid_also") val invalidAlso: Boolean? = null, + /** + * Only group grants whose email domain matches this domain. + */ @Json(name = "specific_domain") val specificDomain: String? = null, ) { diff --git a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt index 86d4b622..7f0724ec 100644 --- a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt @@ -123,7 +123,8 @@ class ApplicationsTests { val request = UpdateApplicationRequest.Builder() .callbackUris( listOf( - CreateRedirectUriRequest( + UpdateApplicationRedirectUriRequest( + id = "0556d035-6cb6-4262-a035-6b77e11cf8fc", url = "https://example.com/callback", platform = Platform.WEB, ), @@ -134,9 +135,9 @@ class ApplicationsTests { val json = adapter.toJson(request) assert(json.contains("\"callback_uris\"")) { "Expected callback_uris in JSON, got: $json" } + assert(json.contains("\"id\":\"0556d035-6cb6-4262-a035-6b77e11cf8fc\"")) { "Expected callback URI id in JSON, got: $json" } assert(json.contains("\"url\":\"https://example.com/callback\"")) { "Expected callback URL in JSON, got: $json" } assert(json.contains("\"platform\":\"web\"")) { "Expected platform in JSON, got: $json" } - assert(!json.contains("\"id\"")) { "Expected callback URI id to be omitted from JSON, got: $json" } } @Test diff --git a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt index 395a8b0e..686911d0 100644 --- a/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -129,6 +129,21 @@ class WorkspacesTests { assert(json.contains("\"policy_id\":null")) { "Expected policy_id:null in JSON, got: $json" } } + + @Test + fun `WorkspaceAutoGroupRequest serializes all supported filters`() { + val adapter = JsonHelper.moshi().adapter(WorkspaceAutoGroupRequest::class.java) + val request = WorkspaceAutoGroupRequest.Builder() + .afterCreatedAt(1742933005) + .invalidAlso(true) + .specificDomain("acme.com") + .build() + + assertEquals( + """{"after_created_at":1742933005,"invalid_also":true,"specific_domain":"acme.com"}""", + adapter.toJson(request), + ) + } } @Nested