diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 964d0c51..0165ab35 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,6 +10,8 @@ "Bash(./gradlew clean build:*)", "Bash(java -version:*)", "Bash(/usr/libexec/java_home:*)", + "Bash(./gradlew clean test:*)", + "Bash(./gradlew:*)", "Bash(./gradlew build:*)" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a38b5fa..d46364a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ - `activeCredentialId` field in `Connector` response model - `activeCredentialId` field in `UpdateConnectorRequest` for setting the active credential on a Connector * Enhanced `CredentialData.ConnectorOverride` to support optional `clientId` and `clientSecret` fields -* Support for `specific_time_availability` field in `AvailabilityParticipant` to override open hours configurations for specific dates and time ranges +* Support for `specific_time_availability` and `only_specific_time_availability` fields in `AvailabilityParticipant` to override open hours configurations for specific dates and time ranges +* `smtpRequired` field in `UrlForAuthenticationConfig` to ensure users enter their SMTP settings during hosted authentication ### Deprecated * `CreateCredentialRequest.Override` - Use `CreateCredentialRequest.Connector` instead diff --git a/src/main/kotlin/com/nylas/models/AvailabilityParticipant.kt b/src/main/kotlin/com/nylas/models/AvailabilityParticipant.kt index 7c7ee322..e28bf7cd 100644 --- a/src/main/kotlin/com/nylas/models/AvailabilityParticipant.kt +++ b/src/main/kotlin/com/nylas/models/AvailabilityParticipant.kt @@ -28,6 +28,12 @@ data class AvailabilityParticipant( */ @Json(name = "specific_time_availability") val specificTimeAvailability: List? = null, + /** + * When set to true, only the times specified in [specificTimeAvailability] are considered available, + * ignoring the [openHours] configuration. + */ + @Json(name = "only_specific_time_availability") + val onlySpecificTimeAvailability: Boolean? = null, ) { /** * A builder for creating an [AvailabilityParticipant]. @@ -39,6 +45,7 @@ data class AvailabilityParticipant( private var calendarIds: List? = null private var openHours: List? = null private var specificTimeAvailability: List? = null + private var onlySpecificTimeAvailability: Boolean? = null /** * Set the calendar IDs associated with each participant's email address. @@ -61,6 +68,14 @@ data class AvailabilityParticipant( */ fun specificTimeAvailability(specificTimeAvailability: List) = apply { this.specificTimeAvailability = specificTimeAvailability } + /** + * Set whether only the times specified in [SpecificTimeAvailability] are considered available. + * When set to true, the [OpenHours] configuration is ignored. + * @param onlySpecificTimeAvailability Whether to only use specific time availability. + * @return The builder. + */ + fun onlySpecificTimeAvailability(onlySpecificTimeAvailability: Boolean) = apply { this.onlySpecificTimeAvailability = onlySpecificTimeAvailability } + /** * Build the [AvailabilityParticipant]. * @return The [AvailabilityParticipant]. @@ -70,6 +85,7 @@ data class AvailabilityParticipant( calendarIds, openHours, specificTimeAvailability, + onlySpecificTimeAvailability, ) } } diff --git a/src/main/kotlin/com/nylas/models/SpecificTimeAvailability.kt b/src/main/kotlin/com/nylas/models/SpecificTimeAvailability.kt index 6d5a47c4..763bab47 100644 --- a/src/main/kotlin/com/nylas/models/SpecificTimeAvailability.kt +++ b/src/main/kotlin/com/nylas/models/SpecificTimeAvailability.kt @@ -22,17 +22,25 @@ data class SpecificTimeAvailability( */ @Json(name = "end") val end: String, + /** + * IANA time zone database formatted string (e.g. America/Toronto). + * @see List of tz database time zones + */ + @Json(name = "timezone") + val timezone: String, ) { /** * A builder for creating a [SpecificTimeAvailability]. * @param date The date in YYYY-MM-DD format. * @param start The start time in HH:MM format. * @param end The end time in HH:MM format. + * @param timezone IANA time zone database formatted string (e.g. America/Toronto). */ data class Builder( private val date: String, private val start: String, private val end: String, + private val timezone: String, ) { /** * Build the [SpecificTimeAvailability]. @@ -42,6 +50,7 @@ data class SpecificTimeAvailability( date, start, end, + timezone, ) } } diff --git a/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt b/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt index c4be9804..621c2043 100644 --- a/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt +++ b/src/main/kotlin/com/nylas/models/UrlForAuthenticationConfig.kt @@ -60,6 +60,12 @@ data class UrlForAuthenticationConfig( */ @Json(name = "credential_id") val credentialId: String? = null, + /** + * If set to true, the user will be required to enter their SMTP settings during authentication. + * This is useful for IMAP-based providers to ensure SMTP credentials are collected for sending email. + */ + @Json(name = "smtp_required") + val smtpRequired: Boolean? = null, ) { /** * Builder for [UrlForAuthenticationConfig]. @@ -78,6 +84,7 @@ data class UrlForAuthenticationConfig( private var state: String? = null private var loginHint: String? = null private var credentialId: String? = null + private var smtpRequired: Boolean? = null /** * Set the integration provider type that you already had set up with Nylas for this application. @@ -138,6 +145,14 @@ data class UrlForAuthenticationConfig( */ fun credentialId(credentialId: String) = apply { this.credentialId = credentialId } + /** + * Set whether the user is required to enter their SMTP settings during authentication. + * This is useful for IMAP-based providers to ensure SMTP credentials are collected for sending email. + * @param smtpRequired Whether SMTP settings are required. + * @return This builder. + */ + fun smtpRequired(smtpRequired: Boolean) = apply { this.smtpRequired = smtpRequired } + /** * Build the [UrlForAuthenticationConfig]. * @return The [UrlForAuthenticationConfig]. @@ -153,6 +168,7 @@ data class UrlForAuthenticationConfig( state, loginHint, credentialId, + smtpRequired, ) } } diff --git a/src/test/kotlin/com/nylas/resources/AuthTests.kt b/src/test/kotlin/com/nylas/resources/AuthTests.kt index 6868e47f..6d0bbb56 100644 --- a/src/test/kotlin/com/nylas/resources/AuthTests.kt +++ b/src/test/kotlin/com/nylas/resources/AuthTests.kt @@ -146,6 +146,102 @@ class AuthTests { assertEquals(AuthProvider.GOOGLE, config.provider) assertEquals("cred-456", config.credentialId) } + + @Test + fun `urlForOAuth2 with smtpRequired true should include smtp_required=true in URL`() { + val configWithSmtp = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + provider = AuthProvider.IMAP, + smtpRequired = true, + ) + + val url = auth.urlForOAuth2(configWithSmtp) + + assert(url.contains("smtp_required=true")) + assert(url.contains("response_type=code")) + } + + @Test + fun `urlForOAuth2 with smtpRequired false should include smtp_required=false in URL`() { + val configWithSmtp = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + provider = AuthProvider.IMAP, + smtpRequired = false, + ) + + val url = auth.urlForOAuth2(configWithSmtp) + + assert(url.contains("smtp_required=false")) + assert(url.contains("response_type=code")) + } + + @Test + fun `urlForOAuth2 without smtpRequired should not include smtp_required in URL`() { + val configWithoutSmtp = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + provider = AuthProvider.IMAP, + ) + + val url = auth.urlForOAuth2(configWithoutSmtp) + + assert(!url.contains("smtp_required")) + } + + @Test + fun `urlForOAuth2PKCE with smtpRequired true should include smtp_required=true in URL`() { + val configWithSmtp = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + provider = AuthProvider.IMAP, + smtpRequired = true, + ) + + val pkceAuthURL = auth.urlForOAuth2PKCE(configWithSmtp) + + assert(pkceAuthURL.url.contains("smtp_required=true")) + assert(pkceAuthURL.url.contains("response_type=code")) + assert(pkceAuthURL.url.contains("code_challenge_method=s256")) + } + + @Test + fun `urlForAdminConsent with smtpRequired true should include smtp_required=true in URL`() { + val configWithSmtp = UrlForAuthenticationConfig( + clientId = "abc-123", + redirectUri = "https://example.com/oauth/callback", + provider = AuthProvider.MICROSOFT, + smtpRequired = true, + ) + + val url = auth.urlForAdminConsent(configWithSmtp, "cred-789") + + assert(url.contains("smtp_required=true")) + assert(url.contains("response_type=adminconsent")) + } + + @Test + fun `UrlForAuthenticationConfig Builder with smtpRequired works correctly`() { + val config = UrlForAuthenticationConfig.Builder("client-123", "https://example.com/callback") + .provider(AuthProvider.IMAP) + .smtpRequired(true) + .build() + + assertEquals("client-123", config.clientId) + assertEquals("https://example.com/callback", config.redirectUri) + assertEquals(AuthProvider.IMAP, config.provider) + assertEquals(true, config.smtpRequired) + } + + @Test + fun `UrlForAuthenticationConfig Builder without smtpRequired defaults to null`() { + val config = UrlForAuthenticationConfig.Builder("client-123", "https://example.com/callback") + .provider(AuthProvider.IMAP) + .build() + + assertNull(config.smtpRequired) + } } @Nested diff --git a/src/test/kotlin/com/nylas/resources/CalendarsTest.kt b/src/test/kotlin/com/nylas/resources/CalendarsTest.kt index 3bf73767..3e267487 100644 --- a/src/test/kotlin/com/nylas/resources/CalendarsTest.kt +++ b/src/test/kotlin/com/nylas/resources/CalendarsTest.kt @@ -105,14 +105,15 @@ class CalendarsTest { @Nested inner class SpecificTimeAvailabilityTests { @Test - fun `SpecificTimeAvailability serializes properly`() { + fun `SpecificTimeAvailability deserializes properly`() { val adapter = JsonHelper.moshi().adapter(SpecificTimeAvailability::class.java) val jsonBuffer = Buffer().writeUtf8( """ { "date": "2026-03-18", "start": "09:00", - "end": "17:00" + "end": "17:00", + "timezone": "America/Toronto" } """.trimIndent(), ) @@ -122,6 +123,7 @@ class CalendarsTest { assertEquals("2026-03-18", specificTimeAvailability.date) assertEquals("09:00", specificTimeAvailability.start) assertEquals("17:00", specificTimeAvailability.end) + assertEquals("America/Toronto", specificTimeAvailability.timezone) } @Test @@ -131,10 +133,11 @@ class CalendarsTest { date = "2026-03-18", start = "09:00", end = "17:00", + timezone = "America/Toronto", ) val json = adapter.toJson(specificTimeAvailability) - assertEquals("""{"date":"2026-03-18","start":"09:00","end":"17:00"}""", json) + assertEquals("""{"date":"2026-03-18","start":"09:00","end":"17:00","timezone":"America/Toronto"}""", json) } @Test @@ -144,6 +147,7 @@ class CalendarsTest { date = "2026-03-18", start = "09:00", end = "17:00", + timezone = "America/Toronto", ) val json = adapter.toJson(original) @@ -151,6 +155,7 @@ class CalendarsTest { assertEquals(original.date, deserialized.date) assertEquals(original.start, deserialized.start) assertEquals(original.end, deserialized.end) + assertEquals(original.timezone, deserialized.timezone) } @Test @@ -159,15 +164,17 @@ class CalendarsTest { date = "2026-03-18", start = "09:00", end = "17:00", + timezone = "America/Toronto", ).build() assertEquals("2026-03-18", specificTimeAvailability.date) assertEquals("09:00", specificTimeAvailability.start) assertEquals("17:00", specificTimeAvailability.end) + assertEquals("America/Toronto", specificTimeAvailability.timezone) } @Test - fun `AvailabilityParticipant serializes with specificTimeAvailability`() { + fun `AvailabilityParticipant serializes with specificTimeAvailability and onlySpecificTimeAvailability`() { val adapter = JsonHelper.moshi().adapter(AvailabilityParticipant::class.java) val participant = AvailabilityParticipant( email = "test@nylas.com", @@ -177,8 +184,10 @@ class CalendarsTest { date = "2026-03-18", start = "09:00", end = "17:00", + timezone = "America/Toronto", ), ), + onlySpecificTimeAvailability = true, ) val json = adapter.toJson(participant) @@ -188,10 +197,12 @@ class CalendarsTest { assertEquals("2026-03-18", deserialized.specificTimeAvailability?.get(0)?.date) assertEquals("09:00", deserialized.specificTimeAvailability?.get(0)?.start) assertEquals("17:00", deserialized.specificTimeAvailability?.get(0)?.end) + assertEquals("America/Toronto", deserialized.specificTimeAvailability?.get(0)?.timezone) + assertEquals(true, deserialized.onlySpecificTimeAvailability) } @Test - fun `AvailabilityParticipant serializes without specificTimeAvailability for backward compatibility`() { + fun `AvailabilityParticipant serializes without new fields for backward compatibility`() { val adapter = JsonHelper.moshi().adapter(AvailabilityParticipant::class.java) val participant = AvailabilityParticipant( email = "test@nylas.com", @@ -202,11 +213,12 @@ class CalendarsTest { val deserialized = adapter.fromJson(json)!! assertEquals("test@nylas.com", deserialized.email) assertEquals(null, deserialized.specificTimeAvailability) + assertEquals(null, deserialized.onlySpecificTimeAvailability) assertEquals(null, deserialized.openHours) } @Test - fun `AvailabilityParticipant deserializes JSON with specific_time_availability`() { + fun `AvailabilityParticipant deserializes JSON with all new fields`() { val adapter = JsonHelper.moshi().adapter(AvailabilityParticipant::class.java) val jsonBuffer = Buffer().writeUtf8( """ @@ -217,9 +229,11 @@ class CalendarsTest { { "date": "2026-03-18", "start": "09:00", - "end": "17:00" + "end": "17:00", + "timezone": "America/Toronto" } - ] + ], + "only_specific_time_availability": true } """.trimIndent(), ) @@ -231,10 +245,12 @@ class CalendarsTest { assertEquals("2026-03-18", participant.specificTimeAvailability?.get(0)?.date) assertEquals("09:00", participant.specificTimeAvailability?.get(0)?.start) assertEquals("17:00", participant.specificTimeAvailability?.get(0)?.end) + assertEquals("America/Toronto", participant.specificTimeAvailability?.get(0)?.timezone) + assertEquals(true, participant.onlySpecificTimeAvailability) } @Test - fun `AvailabilityParticipant deserializes JSON without specific_time_availability for backward compatibility`() { + fun `AvailabilityParticipant deserializes JSON without new fields for backward compatibility`() { val adapter = JsonHelper.moshi().adapter(AvailabilityParticipant::class.java) val jsonBuffer = Buffer().writeUtf8( """ @@ -248,10 +264,11 @@ class CalendarsTest { val participant = adapter.fromJson(jsonBuffer)!! assertEquals("test@nylas.com", participant.email) assertEquals(null, participant.specificTimeAvailability) + assertEquals(null, participant.onlySpecificTimeAvailability) } @Test - fun `AvailabilityParticipant Builder works with specificTimeAvailability`() { + fun `AvailabilityParticipant Builder works with all new fields`() { val participant = AvailabilityParticipant.Builder("test@nylas.com") .calendarIds(listOf("primary")) .specificTimeAvailability( @@ -260,9 +277,11 @@ class CalendarsTest { date = "2026-03-18", start = "09:00", end = "17:00", + timezone = "America/Toronto", ), ), ) + .onlySpecificTimeAvailability(true) .build() assertEquals("test@nylas.com", participant.email) @@ -271,16 +290,19 @@ class CalendarsTest { assertEquals("2026-03-18", participant.specificTimeAvailability?.get(0)?.date) assertEquals("09:00", participant.specificTimeAvailability?.get(0)?.start) assertEquals("17:00", participant.specificTimeAvailability?.get(0)?.end) + assertEquals("America/Toronto", participant.specificTimeAvailability?.get(0)?.timezone) + assertEquals(true, participant.onlySpecificTimeAvailability) } @Test - fun `AvailabilityParticipant Builder works without specificTimeAvailability for backward compatibility`() { + fun `AvailabilityParticipant Builder works without new fields for backward compatibility`() { val participant = AvailabilityParticipant.Builder("test@nylas.com") .calendarIds(listOf("calendar-123")) .build() assertEquals("test@nylas.com", participant.email) assertEquals(null, participant.specificTimeAvailability) + assertEquals(null, participant.onlySpecificTimeAvailability) } } @@ -527,19 +549,21 @@ class CalendarsTest { fun `getting availability with specificTimeAvailability calls requests with the correct params`() { val adapter = JsonHelper.moshi().adapter(GetAvailabilityRequest::class.java) val getAvailabilityRequest = GetAvailabilityRequest( - startTime = 1737540000, - endTime = 1737712800, + startTime = 1773248400, + endTime = 1774458000, participants = listOf( AvailabilityParticipant( - email = "nylastest8@gmail.com", + email = "teotoplak95@gmail.com", calendarIds = listOf("primary"), specificTimeAvailability = listOf( SpecificTimeAvailability( date = "2026-03-18", start = "09:00", end = "17:00", + timezone = "America/Toronto", ), ), + onlySpecificTimeAvailability = true, ), ), durationMinutes = 30,