diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2d505c..721847f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] ### Added +* Application administration updates + - `Applications.update()` for `PATCH /v3/applications` + - 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; `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 @@ -10,8 +16,9 @@ - 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 + - `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/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index 38472bb0..722aa457 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 @@ -355,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/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/CreateNylasListRequest.kt b/src/main/kotlin/com/nylas/models/CreateNylasListRequest.kt index 6372e7f4..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, 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..f73e58a8 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/CreateWorkspaceRequest.kt @@ -0,0 +1,38 @@ +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 = "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 + private var policyId: String? = 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 ruleIds(ruleIds: List?) = apply { this.ruleIds = ruleIds } + fun build() = CreateWorkspaceRequest(name, domain, autoGroup, policyId, ruleIds) + } +} 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..9b9e0362 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/Domain.kt @@ -0,0 +1,39 @@ +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 = "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..a1042b5c --- /dev/null +++ b/src/main/kotlin/com/nylas/models/DomainVerification.kt @@ -0,0 +1,123 @@ +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, +} + +/** + * 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. + */ +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: DomainVerificationRequestType, + @Json(name = "options") + val options: Map? = null, +) { + /** + * Builder for [DomainVerificationRequest]. + */ + data class Builder(private val type: DomainVerificationRequestType) { + 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..43ee4a80 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/ListDomainsQueryParams.kt @@ -0,0 +1,22 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representation of the query parameters for listing domains. + */ +data class ListDomainsQueryParams( + @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() = ListDomainsQueryParams(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/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/models/RequestOverrides.kt b/src/main/kotlin/com/nylas/models/RequestOverrides.kt index fd050b80..0be28224 100644 --- a/src/main/kotlin/com/nylas/models/RequestOverrides.kt +++ b/src/main/kotlin/com/nylas/models/RequestOverrides.kt @@ -21,6 +21,12 @@ data class RequestOverrides( */ val headers: Map? = emptyMap(), ) { + /** + * Omit the default Authorization header for requests that use another authentication scheme. + * @suppress Not for public use. + */ + internal var omitAuthorization: Boolean = false + /** * Builder for [RequestOverrides]. */ 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/ServiceAccountSigner.kt b/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt new file mode 100644 index 00000000..847793bd --- /dev/null +++ b/src/main/kotlin/com/nylas/models/ServiceAccountSigner.kt @@ -0,0 +1,195 @@ +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 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-----", "") + .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 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) + } + + 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/models/UpdateApplicationRequest.kt b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt new file mode 100644 index 00000000..ce8aa021 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/UpdateApplicationRequest.kt @@ -0,0 +1,114 @@ +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: Branding? = null, + /** + * Hosted authentication branding details. + */ + @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]. + */ + class Builder { + private var branding: Branding? = null + private var hostedAuthentication: ApplicationDetails.HostedAuthentication? = null + private var callbackUris: List? = null + + /** + * Set branding details for the application. + * @param branding Branding details. + * @return This builder. + */ + fun branding(branding: 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 } + + /** + * 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, 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, + ) +} + +/** + * 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/UpdateDomainRequest.kt b/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt new file mode 100644 index 00000000..f7e9ad14 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/UpdateDomainRequest.kt @@ -0,0 +1,22 @@ +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, +) { + /** + * Builder for [UpdateDomainRequest]. + */ + class Builder { + private var name: String? = null + + fun name(name: String?) = apply { this.name = name } + + fun build() = UpdateDomainRequest(name) + } +} 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..5a85dc2a --- /dev/null +++ b/src/main/kotlin/com/nylas/models/UpdateWorkspaceRequest.kt @@ -0,0 +1,31 @@ +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 = "auto_group") + val autoGroup: Boolean? = null, + @Json(name = "policy_id") + val policyId: NullableField? = null, + @Json(name = "rule_ids") + val ruleIds: List? = null, +) { + class Builder { + private var name: String? = null + private var autoGroup: Boolean? = null + private var policyId: NullableField? = null + private var ruleIds: List? = null + + fun name(name: String?) = apply { this.name = name } + fun autoGroup(autoGroup: Boolean?) = apply { this.autoGroup = autoGroup } + 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/main/kotlin/com/nylas/models/Workspace.kt b/src/main/kotlin/com/nylas/models/Workspace.kt new file mode 100644 index 00000000..34088b8a --- /dev/null +++ b/src/main/kotlin/com/nylas/models/Workspace.kt @@ -0,0 +1,29 @@ +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 = "default") + val default: Boolean? = null, + @Json(name = "policy_id") + val policyId: String? = null, + @Json(name = "rule_ids") + val ruleIds: 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..4a53cd1e --- /dev/null +++ b/src/main/kotlin/com/nylas/models/WorkspaceAutoGroupRequest.kt @@ -0,0 +1,35 @@ +package com.nylas.models + +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, +) { + 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..cd8ad11c 100644 --- a/src/main/kotlin/com/nylas/resources/Domains.kt +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -4,9 +4,40 @@ 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) { + 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 ?: 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).apply { 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) + } /** * Send a transactional email from a verified domain. @@ -37,4 +68,282 @@ class Domains(client: NylasClient) : Resource(client, Message::class.ja createResource(path, serializedRequestBody, overrides = overrides) } } + + /** + * 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 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 + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The list of managed domains + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + 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, 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 + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The managed domain + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + 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 = 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 + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The created managed domain + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + 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 = ServiceAccountSigner.canonicalJson(requestBody) + 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 + * @param requestBody The values to update the domain with + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The updated managed domain fields + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + 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 = ServiceAccountSigner.canonicalJson(requestBody) + 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 + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The deletion response + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + 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 = 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 + * @param requestBody The verification type to inspect + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The domain verification result + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + fun info( + domainId: String, + requestBody: DomainVerificationRequest, + 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 = ServiceAccountSigner.canonicalJson(requestBody) + 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 + * @param requestBody The verification type to verify + * @param overrides Request overrides containing Nylas Service Account signing headers + * @return The domain verification result + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + fun verify( + domainId: String, + requestBody: DomainVerificationRequest, + 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 = ServiceAccountSigner.canonicalJson(requestBody) + 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", + "X-Nylas-Timestamp", + "X-Nylas-Nonce", + "X-Nylas-Signature", + ) + } } diff --git a/src/main/kotlin/com/nylas/resources/Rules.kt b/src/main/kotlin/com/nylas/resources/Rules.kt index 290f7372..df4ef0dc 100644 --- a/src/main/kotlin/com/nylas/resources/Rules.kt +++ b/src/main/kotlin/com/nylas/resources/Rules.kt @@ -4,6 +4,8 @@ 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 /** * Nylas Rules API @@ -23,7 +25,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 { - return listResource("v3/rules", queryParams, overrides) + val nestedResponse = client.executeGet("v3/rules", RulesListResponse::class.java, queryParams, overrides) + return nestedResponse.toListResponse() } /** @@ -79,4 +82,56 @@ 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) + } +} + +/** + * 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/main/kotlin/com/nylas/resources/Workspaces.kt b/src/main/kotlin/com/nylas/resources/Workspaces.kt new file mode 100644 index 00000000..cf1fbd27 --- /dev/null +++ b/src/main/kotlin/com/nylas/resources/Workspaces.kt @@ -0,0 +1,122 @@ +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 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(queryParams: ListWorkspacesQueryParams? = null, overrides: RequestOverrides? = null): ListResponse { + return listResource("v3/workspaces", queryParams, 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/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/RequestOverridesTests.kt b/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt new file mode 100644 index 00000000..08a585ac --- /dev/null +++ b/src/test/kotlin/com/nylas/models/RequestOverridesTests.kt @@ -0,0 +1,43 @@ +package com.nylas.models + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RequestOverridesTests { + @Test + fun `public data class ABI remains four fields`() { + 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) + + 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 + }, + ) + } +} 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..3ba6407c --- /dev/null +++ b/src/test/kotlin/com/nylas/models/ServiceAccountSignerTests.kt @@ -0,0 +1,97 @@ +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(DomainVerificationRequestType.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()) + } + + @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, + "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/ApplicationsTests.kt b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt index 3c19353f..7f0724ec 100644 --- a/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt +++ b/src/test/kotlin/com/nylas/resources/ApplicationsTests.kt @@ -116,6 +116,41 @@ 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( + UpdateApplicationRedirectUriRequest( + 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("\"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" } + } + + @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 @@ -148,6 +183,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 = UpdateApplicationRequest.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..93bd1392 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,10 +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 { @@ -116,6 +125,81 @@ 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 `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() + .limit(5) + .pageToken("cursor123") + .build() + + assertEquals( + mapOf("limit" to 5.0, "page_token" to "cursor123"), + queryParams.convertToMap(), + ) + } } @Nested @@ -124,6 +208,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" @@ -270,5 +363,301 @@ 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") + val overrides = serviceAccountOverrides() + domains.list(queryParams, overrides) + + 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) + assertServiceAccountOverrides(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 `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() + domains.find("domain/with/slash", overrides) + + 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) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) + } + + @Test + fun `creating a managed domain calls requests with the correct params`() { + val requestBody = CreateDomainRequest(name = "Acme", domainAddress = "mail.acme.com") + val overrides = serviceAccountOverrides() + domains.create(requestBody, overrides) + + 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("""{"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 requestBody = UpdateDomainRequest(name = "Renamed") + val overrides = serviceAccountOverrides() + domains.update("dom-123", requestBody, overrides) + + 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("""{"name":"Renamed"}""", requestBodyCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) + } + + @Test + fun `destroying a managed domain calls requests with the correct params`() { + val overrides = serviceAccountOverrides() + domains.destroy("dom-123", overrides) + + 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) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) + } + + @Test + fun `getting managed domain info calls requests with the correct params`() { + val requestBody = DomainVerificationRequest(DomainVerificationRequestType.SPF) + val overrides = serviceAccountOverrides() + domains.info("dom-123", requestBody, overrides) + + 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("""{"type":"spf"}""", requestBodyCaptor.firstValue) + assertServiceAccountOverrides(overrides, overrideParamCaptor.firstValue) + } + + @Test + fun `verifying a managed domain calls requests with the correct params`() { + val requestBody = DomainVerificationRequest(DomainVerificationRequestType.DKIM) + val overrides = serviceAccountOverrides() + domains.verify("dom-123", requestBody, overrides) + + 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("""{"type":"dkim"}""", requestBodyCaptor.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() + } } } diff --git a/src/test/kotlin/com/nylas/resources/NylasListsTests.kt b/src/test/kotlin/com/nylas/resources/NylasListsTests.kt index 9fb64782..e080c6a5 100644 --- a/src/test/kotlin/com/nylas/resources/NylasListsTests.kt +++ b/src/test/kotlin/com/nylas/resources/NylasListsTests.kt @@ -101,12 +101,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 diff --git a/src/test/kotlin/com/nylas/resources/RulesTests.kt b/src/test/kotlin/com/nylas/resources/RulesTests.kt index fa5fafd2..6fa97284 100644 --- a/src/test/kotlin/com/nylas/resources/RulesTests.kt +++ b/src/test/kotlin/com/nylas/resources/RulesTests.kt @@ -7,8 +7,11 @@ 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 @@ -19,6 +22,7 @@ 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 @@ -244,6 +248,44 @@ class RulesTests { assertNull(rule.match) assertNull(rule.description) } + + @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,42 +301,44 @@ class RulesTests { @Test fun `listing rules calls requests with the correct params`() { - rules.list() + val capturedRequest = AtomicReference() + val client = NylasClient("api-key", rulesListClient(capturedRequest, rulesListResponse(flat = true)), "https://api.test.nylas.com/") - 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(Types.newParameterizedType(ListResponse::class.java, Rule::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") - rules.list(queryParams) + val capturedRequest = AtomicReference() + val client = NylasClient("api-key", rulesListClient(capturedRequest, rulesListResponse(flat = true)), "https://api.test.nylas.com/") - val pathCaptor = argumentCaptor() - val typeCaptor = argumentCaptor() - val queryParamCaptor = argumentCaptor() - val overrideParamCaptor = argumentCaptor() - verify(mockNylasClient).executeGet>( - pathCaptor.capture(), - typeCaptor.capture(), - queryParamCaptor.capture(), - overrideParamCaptor.capture(), - ) + client.rules().list(queryParams) - assertEquals("v3/rules", pathCaptor.firstValue) - assertEquals(queryParams, queryParamCaptor.firstValue) + val requestUrl = capturedRequest.get().url + assertEquals("/v3/rules", requestUrl.encodedPath) + assertEquals("5", requestUrl.queryParameter("limit")) + assertEquals("cursor123", requestUrl.queryParameter("page_token")) + } + + @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 @@ -395,5 +439,61 @@ 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) + } + + 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}""" + } } } 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..686911d0 --- /dev/null +++ b/src/test/kotlin/com/nylas/resources/WorkspacesTests.kt @@ -0,0 +1,335 @@ +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.assertFailsWith +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, + "default": true, + "policy_id": "policy-123", + "rule_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(true, workspace.default) + assertEquals(listOf("rule-1"), workspace.ruleIds) + } + + @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) + } + + @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) + } + + @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 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" } + } + + @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 + 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) + 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 + 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) + } + } +}