diff --git a/CHANGELOG.md b/CHANGELOG.md index d6fbdb78..583644fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Added * Optional `TrackingOptions.domainName` support for custom link and open tracking hostnames in regular sends, Transactional Send, drafts, and scheduled sends. The field serializes as `tracking_options.domain_name` and is omitted when unset. +### Fixed +* `metadata_pair` (calendars, events, messages) is now sent as a single `key:value` query parameter using the first map entry, instead of one repeated parameter per entry. The API accepts exactly one pair, so maps with more than one entry previously had all but one entry silently ignored. Public types are unchanged (`Map`); single-entry and empty maps produce the same request as before. +* `to`, `from`, `cc`, `bcc`, and `in` (messages, threads, drafts) are now sent as a single query parameter using the first list entry, instead of as repeated parameters. The API binds each as a scalar string and validates `to`/`from`/`cc`/`bcc` as one email address, so repeated parameters meant the API silently kept just one value — whichever its parser happened to keep last. Sending one parameter makes that deterministic and matches the behavior `inFolder` already documented. Public types are unchanged (`List`); passing a single-element list produces the same request as before. These parameters will change type to `String` in a future major version. +* `attendees` (events) and `any_email` (messages, threads, drafts) are now sent as a single comma-delimited query parameter instead of repeated parameters. The API parses both as comma-delimited strings, so passing more than one value previously caused the API to keep only the last one, returning silently incorrect, order-dependent results. Public types are unchanged (`List`), and null, empty, and single-element values produce the same request as before. Note that `any_email` accepts at most 25 addresses; over-long lists now surface an API validation error instead of silently filtering on a single address. `event_type` is unaffected — the API genuinely supports repeating it. + ## [v2.18.0] - Release 2026-07-10 ### Added diff --git a/src/main/kotlin/com/nylas/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index ec750d2c..166742d1 100644 --- a/src/main/kotlin/com/nylas/NylasClient.kt +++ b/src/main/kotlin/com/nylas/NylasClient.kt @@ -656,14 +656,16 @@ open class NylasClient( for ((key, value) in params) { when (value) { is List<*> -> { + // Parameters the API does not accept as repeated values are already collapsed by + // the query parameter class itself, via IQueryParams.convertToMap. for (item in value) { url.addQueryParameter(key, item.toString()) } } is Map<*, *> -> { - for ((k, v) in value) { - url.addQueryParameter(key, "$k:$v") - } + // Only metadata_pair is a map, and the API accepts exactly one `key:value` pair — + // repeating the parameter makes it keep just one and silently ignore the rest. + value.entries.firstOrNull()?.let { (k, v) -> url.addQueryParameter(key, "$k:$v") } } is Double -> { url.addQueryParameter(key, value.toInt().toString()) diff --git a/src/main/kotlin/com/nylas/models/IQueryParams.kt b/src/main/kotlin/com/nylas/models/IQueryParams.kt index e1eedc2a..f0795006 100644 --- a/src/main/kotlin/com/nylas/models/IQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/IQueryParams.kt @@ -22,3 +22,48 @@ interface IQueryParams { return JsonHelper.jsonMapAdapter.fromJson(json)!! } } + +/** + * Rewrite the named `List` query parameters, which the API does not accept as repeated + * parameters. An empty list drops the parameter entirely; values that are not lists, and keys + * that are absent, are left untouched. + * + * Each query parameter class declares its own affected keys, rather than [com.nylas.NylasClient] + * holding a central list: parameter names are only meaningful per endpoint, and several of them + * (`to`, `from`, `cc`, `bcc`) are also request *body* field names, where repeated values are + * correct and collapsing them would silently drop recipients. + */ +private fun Map.rewriteListParams( + keys: Array, + rewrite: (List<*>) -> String, +): Map { + val rewritten = toMutableMap() + for (key in keys) { + val value = rewritten[key] + if (value is List<*>) { + if (value.isEmpty()) { + rewritten.remove(key) + } else { + rewritten[key] = rewrite(value) + } + } + } + return rewritten +} + +/** + * Join query parameters that the API parses as a single comma-delimited string. Repeating them + * would make the API keep only one value and silently drop the rest. + */ +internal fun Map.joinCommaDelimitedParams(vararg keys: String): Map = + rewriteListParams(keys) { list -> list.joinToString(",") } + +/** + * Send only the first entry of query parameters that the API accepts as a single value, but which + * this SDK still types as `List` for backwards compatibility. + * + * The API keeps exactly one value for these regardless; sending one parameter makes which value + * wins deterministic instead of dependent on the API parser's repeated-parameter behavior. + */ +internal fun Map.collapseSingleValueParams(vararg keys: String): Map = + rewriteListParams(keys) { list -> list.first().toString() } diff --git a/src/main/kotlin/com/nylas/models/ListDraftsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListDraftsQueryParams.kt index 714e12c6..80693184 100644 --- a/src/main/kotlin/com/nylas/models/ListDraftsQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListDraftsQueryParams.kt @@ -25,21 +25,41 @@ data class ListDraftsQueryParams( val subject: String? = null, /** * Return emails that have been sent or received from this list of email addresses. + * Pass one email address per list entry; the SDK sends them to the API as a single + * comma-delimited value. The API accepts a maximum of 25 addresses. */ @Json(name = "any_email") val anyEmail: List? = null, /** * Return items containing drafts to be sent these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "to") val to: List? = null, /** * Return items containing drafts cc'ing these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "cc") val cc: List? = null, /** * Return items containing drafts bcc'ing these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "bcc") val bcc: List? = null, @@ -64,6 +84,16 @@ data class ListDraftsQueryParams( @Json(name = "has_attachment") val hasAttachment: Boolean? = null, ) : IQueryParams { + + /** + * The API does not accept repeated values for these parameters. + * See [joinCommaDelimitedParams] and [collapseSingleValueParams]. + */ + override fun convertToMap(): Map = + super.convertToMap() + .collapseSingleValueParams("to", "cc", "bcc") + .joinCommaDelimitedParams("any_email") + /** * Builder for [ListDraftsQueryParams]. */ diff --git a/src/main/kotlin/com/nylas/models/ListEventQueryParams.kt b/src/main/kotlin/com/nylas/models/ListEventQueryParams.kt index 6bd851d4..20671dbb 100644 --- a/src/main/kotlin/com/nylas/models/ListEventQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListEventQueryParams.kt @@ -112,7 +112,10 @@ data class ListEventQueryParams( val updatedAfter: Long? = null, /** * Filter for events that include the specified attendees. - * This parameter accepts a comma-delimited list of email addresses. + * Pass one email address per list entry; the SDK sends them to the API as a single + * comma-delimited value. + * Note: multiple attendees are matched with AND — an event must include every address + * listed to be returned. * (Not supported for virtual calendars) */ @Json(name = "attendees") @@ -135,6 +138,14 @@ data class ListEventQueryParams( @Json(name = "tentative_as_busy") val tentativeAsBusy: Boolean? = null, ) : IQueryParams { + + /** + * The API does not accept repeated values for this parameter. + * See [joinCommaDelimitedParams]. + */ + override fun convertToMap(): Map = + super.convertToMap().joinCommaDelimitedParams("attendees") + /** * Builder for [ListEventQueryParams]. */ @@ -295,7 +306,10 @@ data class ListEventQueryParams( /** * Sets the attendees to filter for events with. - * This parameter accepts a comma-delimited list of email addresses. + * Pass one email address per list entry; the SDK sends them to the API as a single + * comma-delimited value. + * Note: multiple attendees are matched with AND — an event must include every address + * listed to be returned. * (Not supported for virtual calendars) * @param attendees The attendees to filter for events with. * @return The builder. diff --git a/src/main/kotlin/com/nylas/models/ListMessagesQueryParams.kt b/src/main/kotlin/com/nylas/models/ListMessagesQueryParams.kt index 2768ac07..baace616 100644 --- a/src/main/kotlin/com/nylas/models/ListMessagesQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListMessagesQueryParams.kt @@ -25,31 +25,63 @@ data class ListMessagesQueryParams( val subject: String? = null, /** * Return emails that have been sent or received from this list of email addresses. + * Pass one email address per list entry; the SDK sends them to the API as a single + * comma-delimited value. The API accepts a maximum of 25 addresses. */ @Json(name = "any_email") val anyEmail: List? = null, /** * Return items containing messages sent to these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "to") val to: List? = null, /** * Return items containing messages sent from these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "from") val from: List? = null, /** * Return items containing messages cc'd on these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "cc") val cc: List? = null, /** * Return items containing messages bcc'd on these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "bcc") val bcc: List? = null, /** * Return emails that are in these folder IDs. + * + * Note: the API filters on a single folder or label ID. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "in") val inFolder: List? = null, @@ -100,6 +132,16 @@ data class ListMessagesQueryParams( @Json(name = "metadata_pair") val metadataPair: Map? = null, ) : IQueryParams { + + /** + * The API does not accept repeated values for these parameters. + * See [joinCommaDelimitedParams] and [collapseSingleValueParams]. + */ + override fun convertToMap(): Map = + super.convertToMap() + .collapseSingleValueParams("to", "from", "cc", "bcc", "in") + .joinCommaDelimitedParams("any_email") + class Builder { private var limit: Int? = null private var pageToken: String? = null diff --git a/src/main/kotlin/com/nylas/models/ListThreadsQueryParams.kt b/src/main/kotlin/com/nylas/models/ListThreadsQueryParams.kt index 60f5b93b..f7eab0f8 100644 --- a/src/main/kotlin/com/nylas/models/ListThreadsQueryParams.kt +++ b/src/main/kotlin/com/nylas/models/ListThreadsQueryParams.kt @@ -22,26 +22,52 @@ data class ListThreadsQueryParams( val subject: String? = null, /** * Return emails that have been sent or received from this list of email addresses. + * Pass one email address per list entry; the SDK sends them to the API as a single + * comma-delimited value. The API accepts a maximum of 25 addresses. */ @Json(name = "any_email") val anyEmail: List? = null, /** * Return items containing messages sent to these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "to") val to: List? = null, /** * Return items containing messages sent from these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "from") val from: List? = null, /** * Return items containing messages cc'd on these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "cc") val cc: List? = null, /** * Return items containing messages bcc'd on these email address. + * + * Note: the API filters on a single address. If a list is provided, only the first + * entry is used. + * + * @deprecated The List type for this parameter is deprecated and will be changed to + * String in a future major version. */ @Json(name = "bcc") val bcc: List? = null, @@ -91,22 +117,13 @@ data class ListThreadsQueryParams( ) : IQueryParams { /** - * Override convertToMap to handle the inFolder parameter correctly. - * The API expects a single folder ID, so we use only the first item if a list is provided. + * The API does not accept repeated values for these parameters. + * See [joinCommaDelimitedParams] and [collapseSingleValueParams]. */ - override fun convertToMap(): Map { - val map = super.convertToMap().toMutableMap() - - // Handle inFolder parameter to use only the first item if it's a list - if (inFolder?.isNotEmpty() == true) { - map["in"] = inFolder.first() - } else if (inFolder?.isEmpty() == true) { - // Remove the "in" key if the list is empty - map.remove("in") - } - - return map - } + override fun convertToMap(): Map = + super.convertToMap() + .collapseSingleValueParams("to", "from", "cc", "bcc", "in") + .joinCommaDelimitedParams("any_email") /** * Builder for [ListThreadsQueryParams]. diff --git a/src/test/kotlin/com/nylas/NylasClientTest.kt b/src/test/kotlin/com/nylas/NylasClientTest.kt index 73777385..a833c8c6 100644 --- a/src/test/kotlin/com/nylas/NylasClientTest.kt +++ b/src/test/kotlin/com/nylas/NylasClientTest.kt @@ -224,6 +224,162 @@ class NylasClientTest { } } + @Nested + inner class QueryParamSerializationTests { + private val mockHttpClient: OkHttpClient = mock(OkHttpClient::class.java) + private val mockCall: Call = mock(Call::class.java) + private val mockResponse: Response = mock(Response::class.java) + private val mockResponseBody: ResponseBody = mock(ResponseBody::class.java) + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + val mockOkHttpClientBuilder: OkHttpClient.Builder = mock() + whenever(mockOkHttpClientBuilder.addInterceptor(any())).thenReturn(mockOkHttpClientBuilder) + whenever(mockOkHttpClientBuilder.build()).thenReturn(mockHttpClient) + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + whenever(mockCall.execute()).thenReturn(mockResponse) + whenever(mockResponse.isSuccessful).thenReturn(true) + whenever(mockResponse.body).thenReturn(mockResponseBody) + nylasClient = NylasClient("testApiKey", mockOkHttpClientBuilder) + } + + private fun requestUrlFor(queryParams: IQueryParams): HttpUrl { + whenever(mockResponseBody.source()).thenReturn(Buffer().writeUtf8("{}")) + nylasClient.executeGet>( + "v3/grants/grant-123/events", + JsonHelper.mapTypeOf(String::class.java, String::class.java), + queryParams, + ) + val requestCaptor = argumentCaptor() + verify(mockHttpClient).newCall(requestCaptor.capture()) + return requestCaptor.firstValue.url + } + + @Test + fun `should join attendees into a single comma-delimited param`() { + val url = requestUrlFor( + ListEventQueryParams(calendarId = "primary", attendees = listOf("rumit@x.com", "ale@x.com")), + ) + + assertEquals(listOf("rumit@x.com,ale@x.com"), url.queryParameterValues("attendees")) + // The comma is percent-encoded on the wire; the API url-decodes before splitting. + assertEquals( + "https://api.us.nylas.com/v3/grants/grant-123/events?calendar_id=primary&attendees=rumit%40x.com%2Cale%40x.com", + url.toString(), + ) + } + + @Test + fun `should join any_email into a single comma-delimited param`() { + val url = requestUrlFor(ListMessagesQueryParams(anyEmail = listOf("a@x.com", "b@x.com"))) + + assertEquals(listOf("a@x.com,b@x.com"), url.queryParameterValues("any_email")) + } + + @Test + fun `should leave a single-element list unchanged`() { + val url = requestUrlFor(ListEventQueryParams(calendarId = "primary", attendees = listOf("rumit@x.com"))) + + assertEquals(listOf("rumit@x.com"), url.queryParameterValues("attendees")) + } + + @Test + fun `should omit the param entirely for an empty list`() { + val url = requestUrlFor(ListEventQueryParams(calendarId = "primary", attendees = emptyList())) + + assertEquals(emptyList(), url.queryParameterValues("attendees")) + } + + @Test + fun `should pass through a value the caller already comma-joined`() { + val url = requestUrlFor( + ListEventQueryParams(calendarId = "primary", attendees = listOf("rumit@x.com,ale@x.com")), + ) + + assertEquals(listOf("rumit@x.com,ale@x.com"), url.queryParameterValues("attendees")) + } + + @Test + fun `should still repeat event_type params`() { + val url = requestUrlFor( + ListEventQueryParams( + calendarId = "primary", + eventType = listOf(EventType.DEFAULT, EventType.OUT_OF_OFFICE), + ), + ) + + assertEquals(listOf("default", "outOfOffice"), url.queryParameterValues("event_type")) + } + + @Test + fun `should send only the first value for single-valued params`() { + val url = requestUrlFor( + ListMessagesQueryParams( + to = listOf("a@x.com", "b@x.com"), + from = listOf("c@x.com", "d@x.com"), + cc = listOf("e@x.com", "f@x.com"), + bcc = listOf("g@x.com", "h@x.com"), + inFolder = listOf("folder-1", "folder-2"), + ), + ) + + // The API binds each of these as a scalar string and would keep only one value anyway; + // sending one param makes which value wins deterministic instead of parser-dependent. + assertEquals(listOf("a@x.com"), url.queryParameterValues("to")) + assertEquals(listOf("c@x.com"), url.queryParameterValues("from")) + assertEquals(listOf("e@x.com"), url.queryParameterValues("cc")) + assertEquals(listOf("g@x.com"), url.queryParameterValues("bcc")) + assertEquals(listOf("folder-1"), url.queryParameterValues("in")) + } + + @Test + fun `should collapse single-valued params on threads`() { + val url = requestUrlFor(ListThreadsQueryParams(to = listOf("a@x.com", "b@x.com"))) + + assertEquals(listOf("a@x.com"), url.queryParameterValues("to")) + } + + @Test + fun `should collapse single-valued params on drafts`() { + val url = requestUrlFor(ListDraftsQueryParams(cc = listOf("a@x.com", "b@x.com"))) + + assertEquals(listOf("a@x.com"), url.queryParameterValues("cc")) + } + + @Test + fun `should leave a single-valued param untouched when given one value`() { + val url = requestUrlFor(ListMessagesQueryParams(to = listOf("a@x.com"))) + + assertEquals("https://api.us.nylas.com/v3/grants/grant-123/events?to=a%40x.com", url.toString()) + } + + @Test + fun `should send only the first metadata pair`() { + val url = requestUrlFor( + ListMessagesQueryParams(metadataPair = linkedMapOf("key1" to "value1", "key2" to "value2")), + ) + + // The API accepts exactly one key:value pair and would ignore the rest. + assertEquals(listOf("key1:value1"), url.queryParameterValues("metadata_pair")) + } + + @Test + fun `should omit metadata_pair given an empty map`() { + val url = requestUrlFor(ListMessagesQueryParams(metadataPair = emptyMap())) + + assertEquals(emptyList(), url.queryParameterValues("metadata_pair")) + } + + @Test + fun `should omit single-valued params given an empty list`() { + val url = requestUrlFor(ListMessagesQueryParams(to = emptyList(), cc = emptyList())) + + assertEquals(emptyList(), url.queryParameterValues("to")) + assertEquals(emptyList(), url.queryParameterValues("cc")) + } + } + @Nested inner class RequestTests { private val mockHttpClient: OkHttpClient = mock(OkHttpClient::class.java) @@ -474,7 +630,10 @@ class NylasClientTest { verify(mockHttpClient).newCall(requestCaptor.capture()) val capturedRequest = requestCaptor.firstValue - assertEquals(capturedRequest.url.toString(), "https://api.us.nylas.com/test/path?foo=bar&list=a&list=b&list=c&map=key1%3Avalue1&map=key2%3Avalue2") + // Maps are metadata_pair, which the API accepts as exactly one key:value pair, so only + // the first entry is sent. Lists are repeated; the parameters the API cannot accept + // repeated are collapsed upstream, in IQueryParams.convertToMap. + assertEquals(capturedRequest.url.toString(), "https://api.us.nylas.com/test/path?foo=bar&list=a&list=b&list=c&map=key1%3Avalue1") assertEquals(capturedRequest.method, "GET") }