From 6f6dc3de505c820cd40a92b998b25aefb6a5c79d Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Mon, 20 Apr 2026 15:26:08 +0200 Subject: [PATCH 1/2] TT-4385: add CRUDs for transactional send --- .claude/settings.local.json | 3 +- src/main/kotlin/com/nylas/NylasClient.kt | 6 + .../models/SendTransactionalEmailRequest.kt | 150 ++++++++++ .../kotlin/com/nylas/resources/Domains.kt | 40 +++ src/test/kotlin/com/nylas/NylasClientTest.kt | 6 + .../com/nylas/resources/DomainsTests.kt | 257 ++++++++++++++++++ 6 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/nylas/models/SendTransactionalEmailRequest.kt create mode 100644 src/main/kotlin/com/nylas/resources/Domains.kt create mode 100644 src/test/kotlin/com/nylas/resources/DomainsTests.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0165ab35..da7fb19e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(/usr/libexec/java_home:*)", "Bash(./gradlew clean test:*)", "Bash(./gradlew:*)", - "Bash(./gradlew build:*)" + "Bash(./gradlew build:*)", + "Bash(git checkout *)" ] } } diff --git a/src/main/kotlin/com/nylas/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index dd6130b8..db14c339 100644 --- a/src/main/kotlin/com/nylas/NylasClient.kt +++ b/src/main/kotlin/com/nylas/NylasClient.kt @@ -110,6 +110,12 @@ open class NylasClient( */ open fun drafts(): Drafts = Drafts(this) + /** + * Access the Domains API + * @return The Domains API + */ + open fun domains(): Domains = Domains(this) + /** * Access the Events API * @return The Events API diff --git a/src/main/kotlin/com/nylas/models/SendTransactionalEmailRequest.kt b/src/main/kotlin/com/nylas/models/SendTransactionalEmailRequest.kt new file mode 100644 index 00000000..3c4ecdbb --- /dev/null +++ b/src/main/kotlin/com/nylas/models/SendTransactionalEmailRequest.kt @@ -0,0 +1,150 @@ +package com.nylas.models + +import com.squareup.moshi.Json + +/** + * Class representing a request to send a transactional email from a verified domain. + */ +data class SendTransactionalEmailRequest( + /** + * An array of message recipients. + */ + @Json(name = "to") + val to: List, + /** + * The sender. Must use a verified domain email address. + */ + @Json(name = "from") + val from: EmailName, + /** + * An array of bcc recipients. + */ + @Json(name = "bcc") + val bcc: List? = null, + /** + * An array of cc recipients. + */ + @Json(name = "cc") + val cc: List? = null, + /** + * An array of name and email pairs that override the sent reply-to headers. + * Recommended if there is no Agent Account on the domain to receive replies. + */ + @Json(name = "reply_to") + val replyTo: List? = null, + /** + * An array of files to attach to the message. + */ + @Json(name = "attachments") + override val attachments: List? = null, + /** + * The message subject. + */ + @Json(name = "subject") + val subject: String? = null, + /** + * The full HTML message body. + * Messages with only plain-text representations are up-converted to HTML. + */ + @Json(name = "body") + val body: String? = null, + /** + * Unix timestamp to send the message at. + */ + @Json(name = "send_at") + val sendAt: Long? = null, + /** + * The ID of the message that you are replying to. + */ + @Json(name = "reply_to_message_id") + val replyToMessageId: String? = null, + /** + * Options for tracking opens, links, and thread replies. + */ + @Json(name = "tracking_options") + val trackingOptions: TrackingOptions? = null, + /** + * Whether or not to use draft support. + * This is primarily used when dealing with large attachments. + */ + @Json(name = "use_draft") + val useDraft: Boolean? = null, + /** + * A list of custom headers to add to the message. + */ + @Json(name = "custom_headers") + val customHeaders: List? = null, + /** + * When true, the message body is sent as plain text and the MIME data doesn't include the HTML version of the message. + * When false, the message body is sent as HTML. + */ + @Json(name = "is_plaintext") + val isPlaintext: Boolean? = null, +) : IMessageAttachmentRequest { + /** + * Builder for [SendTransactionalEmailRequest]. + * @property to An array of message recipients. + * @property from The sender. Must use a verified domain email address. + */ + data class Builder( + private val to: List, + private val from: EmailName, + ) { + private var bcc: List? = null + private var cc: List? = null + private var replyTo: List? = null + private var attachments: List? = null + private var subject: String? = null + private var body: String? = null + private var sendAt: Long? = null + private var replyToMessageId: String? = null + private var trackingOptions: TrackingOptions? = null + private var useDraft: Boolean? = null + private var customHeaders: List? = null + private var isPlaintext: Boolean? = null + + fun bcc(bcc: List?) = apply { this.bcc = bcc } + + fun cc(cc: List?) = apply { this.cc = cc } + + fun replyTo(replyTo: List?) = apply { this.replyTo = replyTo } + + fun attachments(attachments: List?) = apply { this.attachments = attachments } + + fun subject(subject: String?) = apply { this.subject = subject } + + fun body(body: String?) = apply { this.body = body } + + fun sendAt(sendAt: Long?) = apply { this.sendAt = sendAt } + + fun sendAt(sendAt: Int?) = apply { this.sendAt = sendAt?.toLong() } + + fun replyToMessageId(replyToMessageId: String?) = apply { this.replyToMessageId = replyToMessageId } + + fun trackingOptions(trackingOptions: TrackingOptions?) = apply { this.trackingOptions = trackingOptions } + + fun useDraft(useDraft: Boolean?) = apply { this.useDraft = useDraft } + + fun customHeaders(customHeaders: List?) = apply { this.customHeaders = customHeaders } + + fun isPlaintext(isPlaintext: Boolean?) = apply { this.isPlaintext = isPlaintext } + + fun build() = + SendTransactionalEmailRequest( + to, + from, + bcc, + cc, + replyTo, + attachments, + subject, + body, + sendAt, + replyToMessageId, + trackingOptions, + useDraft, + customHeaders, + isPlaintext, + ) + } +} diff --git a/src/main/kotlin/com/nylas/resources/Domains.kt b/src/main/kotlin/com/nylas/resources/Domains.kt new file mode 100644 index 00000000..c30b1895 --- /dev/null +++ b/src/main/kotlin/com/nylas/resources/Domains.kt @@ -0,0 +1,40 @@ +package com.nylas.resources + +import com.nylas.NylasClient +import com.nylas.models.* +import com.nylas.util.FileUtils +import com.nylas.util.JsonHelper +import com.squareup.moshi.Types + +class Domains(client: NylasClient) : Resource(client, Message::class.java) { + + /** + * Send a transactional email from a verified domain. + * @param domainName The verified domain name to send from + * @param requestBody The values to send the email with + * @param overrides Optional request overrides to apply + * @return The sent message + */ + @Throws(NylasApiError::class, NylasSdkTimeoutError::class) + @JvmOverloads + fun sendTransactionalEmail( + domainName: String, + requestBody: SendTransactionalEmailRequest, + overrides: RequestOverrides? = null, + ): Response { + val path = String.format("v3/domains/%s/messages/send", domainName) + val responseType = Types.newParameterizedType(Response::class.java, Message::class.java) + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + + val attachmentSize = requestBody.attachments?.sumOf { it.size } ?: 0 + return if (attachmentSize >= FileUtils.MAXIMUM_JSON_ATTACHMENT_SIZE) { + val attachmentLessPayload = requestBody.copy(attachments = null) + val serializedRequestBody = adapter.toJson(attachmentLessPayload) + val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) + client.executeFormRequest(path, NylasClient.HttpMethod.POST, multipart, responseType, overrides = overrides) + } else { + val serializedRequestBody = adapter.toJson(requestBody) + createResource(path, serializedRequestBody, overrides = overrides) + } + } +} diff --git a/src/test/kotlin/com/nylas/NylasClientTest.kt b/src/test/kotlin/com/nylas/NylasClientTest.kt index 339f1515..a1fd5f7a 100644 --- a/src/test/kotlin/com/nylas/NylasClientTest.kt +++ b/src/test/kotlin/com/nylas/NylasClientTest.kt @@ -197,6 +197,12 @@ class NylasClientTest { val result = nylasClient.notetakers() assertNotNull(result) } + + @Test + fun `domains returns a valid Domains instance`() { + val result = nylasClient.domains() + assertNotNull(result) + } } @Nested diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt new file mode 100644 index 00000000..cab55530 --- /dev/null +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -0,0 +1,257 @@ +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 okhttp3.* +import okio.Buffer +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.* +import java.io.ByteArrayInputStream +import java.lang.reflect.Type +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class DomainsTests { + private val mockHttpClient: OkHttpClient = Mockito.mock(OkHttpClient::class.java) + private val mockCall: Call = Mockito.mock(Call::class.java) + private val mockResponse: okhttp3.Response = Mockito.mock(okhttp3.Response::class.java) + private val mockResponseBody: ResponseBody = Mockito.mock(ResponseBody::class.java) + private val mockOkHttpClientBuilder: OkHttpClient.Builder = Mockito.mock() + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + 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) + } + + @Nested + inner class SerializationTests { + @Test + fun `SendTransactionalEmailRequest minimal payload serializes correctly`() { + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ).build() + + val json = adapter.toJson(request) + + // from must be a single object, not an array + assert(json.contains("\"from\":{")) { "Expected 'from' to be a single object, got: $json" } + assert(json.contains("\"to\":[")) { "Expected 'to' to be an array, got: $json" } + assert(!json.contains("null")) { "Expected no null fields in minimal payload, got: $json" } + } + + @Test + fun `SendTransactionalEmailRequest full payload round-trip serializes correctly`() { + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + val jsonBuffer = Buffer().writeUtf8( + """ + { + "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}], + "from": {"name": "ACME Support", "email": "support@acme.com"}, + "cc": [{"name": "CC User", "email": "cc@example.com"}], + "bcc": [{"name": "BCC User", "email": "bcc@example.com"}], + "reply_to": [{"name": "Reply", "email": "reply@example.com"}], + "subject": "Welcome to ACME", + "body": "Welcome! We're here to help.", + "send_at": 1620000000, + "reply_to_message_id": "msg-123", + "tracking_options": {"opens": true, "links": true, "thread_replies": false, "label": "welcome"}, + "use_draft": false, + "custom_headers": [{"name": "X-Custom", "value": "custom-value"}], + "is_plaintext": false + } + """.trimIndent(), + ) + + val request = adapter.fromJson(jsonBuffer)!! + assertIs(request) + assertEquals(1, request.to.size) + assertEquals("jane.doe@example.com", request.to[0].email) + assertEquals("support@acme.com", request.from.email) + assertEquals("ACME Support", request.from.name) + assertEquals(1, request.cc?.size) + assertEquals("cc@example.com", request.cc?.get(0)?.email) + assertEquals(1, request.bcc?.size) + assertEquals("bcc@example.com", request.bcc?.get(0)?.email) + assertEquals(1, request.replyTo?.size) + assertEquals("reply@example.com", request.replyTo?.get(0)?.email) + assertEquals("Welcome to ACME", request.subject) + assertEquals("Welcome! We're here to help.", request.body) + assertEquals(1620000000L, request.sendAt) + assertEquals("msg-123", request.replyToMessageId) + assertEquals(true, request.trackingOptions?.opens) + assertEquals(true, request.trackingOptions?.links) + assertEquals(false, request.trackingOptions?.threadReplies) + assertEquals("welcome", request.trackingOptions?.label) + assertEquals(false, request.useDraft) + assertEquals(1, request.customHeaders?.size) + assertEquals("X-Custom", request.customHeaders?.get(0)?.name) + assertEquals("custom-value", request.customHeaders?.get(0)?.value) + assertEquals(false, request.isPlaintext) + } + + @Test + fun `SendTransactionalEmailRequest isPlaintext true serializes correctly`() { + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ).isPlaintext(true).build() + + val json = adapter.toJson(request) + assert(json.contains("\"is_plaintext\":true")) { "Expected is_plaintext:true in JSON, got: $json" } + } + + @Test + fun `SendTransactionalEmailRequest null optionals are omitted from JSON`() { + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ).build() + + val json = adapter.toJson(request) + assert(!json.contains("\"cc\"")) { "Expected no cc field, got: $json" } + assert(!json.contains("\"bcc\"")) { "Expected no bcc field, got: $json" } + assert(!json.contains("\"subject\"")) { "Expected no subject field, got: $json" } + assert(!json.contains("\"body\"")) { "Expected no body field, got: $json" } + assert(!json.contains("\"send_at\"")) { "Expected no send_at field, got: $json" } + assert(!json.contains("\"is_plaintext\"")) { "Expected no is_plaintext field, got: $json" } + } + } + + @Nested + inner class CrudTests { + private lateinit var domainName: String + private lateinit var mockNylasClient: NylasClient + private lateinit var domains: Domains + + @BeforeEach + fun setup() { + domainName = "acme.com" + mockNylasClient = Mockito.mock(NylasClient::class.java) + domains = Domains(mockNylasClient) + } + + @Test + fun `sending a transactional email calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ) + .subject("Welcome to ACME") + .body("Welcome! We're here to help.") + .build() + + domains.sendTransactionalEmail(domainName, request) + + 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/domains/$domainName/messages/send", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Message::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(request), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `sending a transactional email with overrides passes them through`() { + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ).build() + val overrides = RequestOverrides(apiKey = "override-key") + + domains.sendTransactionalEmail(domainName, request, overrides) + + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + any(), + any(), + any(), + anyOrNull(), + overrideParamCaptor.capture(), + ) + assertEquals("override-key", overrideParamCaptor.firstValue.apiKey) + } + + @Test + fun `sending a transactional email with a large attachment calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(SendTransactionalEmailRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ) + .subject("Welcome to ACME") + .attachments( + listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 3 * 1024 * 1024, + ), + ), + ) + .build() + val attachmentLessRequest = request.copy(attachments = null) + + domains.sendTransactionalEmail(domainName, request) + + val pathCaptor = argumentCaptor() + val methodCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeFormRequest>( + pathCaptor.capture(), + methodCaptor.capture(), + requestBodyCaptor.capture(), + typeCaptor.capture(), + queryParamCaptor.capture(), + overrideParamCaptor.capture(), + ) + + assertEquals("v3/domains/$domainName/messages/send", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Message::class.java), typeCaptor.firstValue) + assertEquals(NylasClient.HttpMethod.POST, methodCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + val multipart = requestBodyCaptor.firstValue as MultipartBody + assertEquals(2, multipart.size) + val buffer = Buffer() + val fileBuffer = Buffer() + multipart.part(0).body.writeTo(buffer) + multipart.part(1).body.writeTo(fileBuffer) + assertEquals(adapter.toJson(attachmentLessRequest), buffer.readUtf8()) + assertEquals("test data", fileBuffer.readUtf8()) + } + } +} From 78193c40e8794bd6d4903a3ccf45856e6b67206e Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Mon, 20 Apr 2026 15:33:43 +0200 Subject: [PATCH 2/2] self review fixes --- .claude/settings.local.json | 3 +- .../com/nylas/resources/DomainsTests.kt | 51 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index da7fb19e..0165ab35 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,8 +12,7 @@ "Bash(/usr/libexec/java_home:*)", "Bash(./gradlew clean test:*)", "Bash(./gradlew:*)", - "Bash(./gradlew build:*)", - "Bash(git checkout *)" + "Bash(./gradlew build:*)" ] } } diff --git a/src/test/kotlin/com/nylas/resources/DomainsTests.kt b/src/test/kotlin/com/nylas/resources/DomainsTests.kt index cab55530..5b1ef036 100644 --- a/src/test/kotlin/com/nylas/resources/DomainsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DomainsTests.kt @@ -10,7 +10,6 @@ import okio.Buffer import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.mockito.Mockito -import org.mockito.MockitoAnnotations import org.mockito.kotlin.* import java.io.ByteArrayInputStream import java.lang.reflect.Type @@ -20,22 +19,6 @@ import kotlin.test.assertIs import kotlin.test.assertNull class DomainsTests { - private val mockHttpClient: OkHttpClient = Mockito.mock(OkHttpClient::class.java) - private val mockCall: Call = Mockito.mock(Call::class.java) - private val mockResponse: okhttp3.Response = Mockito.mock(okhttp3.Response::class.java) - private val mockResponseBody: ResponseBody = Mockito.mock(ResponseBody::class.java) - private val mockOkHttpClientBuilder: OkHttpClient.Builder = Mockito.mock() - - @BeforeEach - fun setup() { - MockitoAnnotations.openMocks(this) - 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) - } @Nested inner class SerializationTests { @@ -253,5 +236,39 @@ class DomainsTests { assertEquals(adapter.toJson(attachmentLessRequest), buffer.readUtf8()) assertEquals("test data", fileBuffer.readUtf8()) } + + @Test + fun `sending a transactional email with a large attachment and overrides passes them through`() { + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val request = SendTransactionalEmailRequest.Builder( + to = listOf(EmailName(email = "jane.doe@example.com", name = "Jane Doe")), + from = EmailName(email = "support@acme.com", name = "ACME Support"), + ) + .attachments( + listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 3 * 1024 * 1024, + ), + ), + ) + .build() + val overrides = RequestOverrides(apiKey = "override-key") + + domains.sendTransactionalEmail(domainName, request, overrides) + + val overrideParamCaptor = argumentCaptor() + verify(mockNylasClient).executeFormRequest>( + any(), + any(), + any(), + any(), + anyOrNull(), + overrideParamCaptor.capture(), + ) + assertEquals("override-key", overrideParamCaptor.firstValue.apiKey) + } } }