From 293a55ac45f26d8ac90961734c504e21f7ff122b Mon Sep 17 00:00:00 2001 From: Klaas Schuijtemaker Date: Wed, 17 Jun 2026 17:47:05 +0200 Subject: [PATCH] various change; --- README.md | 2 +- backend/app/build.gradle.kts | 8 +- .../graphmail/GraphMailAutoConfiguration.kt | 34 +- .../graphmail/GraphMailClient.kt | 6 +- .../graphmail/GraphMailClientImpl.kt | 535 ++++++++++++------ .../GraphMailHttpSecurityConfigurer.kt | 4 +- .../graphmail/GraphMailPlugin.kt | 126 +++-- .../graphmail/GraphMailPluginFactory.kt | 1 - .../graphmail/GraphMailTestSendController.kt | 92 +-- .../GraphMailTokenExpiredException.kt | 5 +- .../graphmail/GraphMailValidation.kt | 14 +- .../graphmail/GraphMailClientTest.kt | 529 ++++++++++++----- .../graphmail/GraphMailPluginTest.kt | 241 ++++++-- .../GraphMailTestSendControllerTest.kt | 73 ++- build.gradle.kts | 3 +- documentation/release-notes.md | 9 + frontend/projects/plugin/plugin.properties | 3 - ...json => valtimo-configurator-metadata.json | 0 18 files changed, 1192 insertions(+), 493 deletions(-) create mode 100644 documentation/release-notes.md delete mode 100644 frontend/projects/plugin/plugin.properties rename backend/plugin/src/main/resources/valtimo-configurator-metadata.json => valtimo-configurator-metadata.json (100%) diff --git a/README.md b/README.md index 43bf7ca..62f3c9e 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,4 @@ Valtimo plugin voor het versturen van e-mail via de Microsoft Graph API met OAut - [Getting Started](documentation/getting-started.md) — installatie en buildinstructies - [Plugin Documentatie](documentation/plugin.md) — pluginconfiguratie, acties en aandachtspunten - +- [Release notes](documentation/release-notes.md) — versiegeschiedenis en wijzigingen diff --git a/backend/app/build.gradle.kts b/backend/app/build.gradle.kts index 972a358..1e153b5 100644 --- a/backend/app/build.gradle.kts +++ b/backend/app/build.gradle.kts @@ -1,11 +1,13 @@ val kotlinLoggingVersion: String by project val nettyResolverDnsNativeMacOsVersion: String by project +val valtimoVersion: String by project + dependencies { - implementation(platform("com.ritense.valtimo:valtimo-dependency-versions")) + implementation(platform("com.ritense.valtimo:valtimo-dependency-versions:$valtimoVersion")) - implementation("com.ritense.valtimo:valtimo-dependencies") - implementation("com.ritense.valtimo:local-mail") + implementation("com.ritense.valtimo:valtimo-dependencies:$valtimoVersion") + implementation("com.ritense.valtimo:local-mail:$valtimoVersion") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.postgresql:postgresql") diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailAutoConfiguration.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailAutoConfiguration.kt index df740da..550de6b 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailAutoConfiguration.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailAutoConfiguration.kt @@ -23,7 +23,6 @@ import java.time.Duration // swallows the rest of the file. Keeping all doc text in line comments avoids that trap. @AutoConfiguration class GraphMailAutoConfiguration { - private val logger = LoggerFactory.getLogger(GraphMailAutoConfiguration::class.java) // Fired once after the full application context is ready. @@ -34,9 +33,9 @@ class GraphMailAutoConfiguration { fun warnOnStartup() { logger.warn( "[Graph Mail Plugin] IMPORTANT: this plugin blocks Operaton job-executor threads during " + - "retry backoff (up to 30s per send, 120s for large attachments). " + - "Set operaton.bpm.job-executor.core-pool-size >= 20 and max-pool-size >= 50 " + - "to prevent job-executor starvation under load. See documentation/plugin.md for details." + "retry backoff (up to 30s per send, 120s for large attachments). " + + "Set operaton.bpm.job-executor.core-pool-size >= 20 and max-pool-size >= 50 " + + "to prevent job-executor starvation under load. See documentation/plugin.md for details.", ) } @@ -46,11 +45,12 @@ class GraphMailAutoConfiguration { restTemplateBuilder: RestTemplateBuilder, objectMapper: ObjectMapper, ): GraphMailClient { - val restTemplate = restTemplateBuilder - .connectTimeout(Duration.ofSeconds(10)) - .readTimeout(Duration.ofSeconds(30)) - .build() - .also { configureJackson(it, objectMapper) } + val restTemplate = + restTemplateBuilder + .connectTimeout(Duration.ofSeconds(10)) + .readTimeout(Duration.ofSeconds(30)) + .build() + .also { configureJackson(it, objectMapper) } return GraphMailClientImpl(RestClient.create(restTemplate)) } @@ -63,9 +63,14 @@ class GraphMailAutoConfiguration { objectMapper: ObjectMapper, resourceStorageService: TemporaryResourceStorageService, eventPublisher: ApplicationEventPublisher, - ): GraphMailPluginFactory = GraphMailPluginFactory( - pluginService, restTemplateBuilder, objectMapper, resourceStorageService, eventPublisher - ) + ): GraphMailPluginFactory = + GraphMailPluginFactory( + pluginService, + restTemplateBuilder, + objectMapper, + resourceStorageService, + eventPublisher, + ) @Bean @ConditionalOnMissingBean(GraphMailTestSendController::class) @@ -80,7 +85,10 @@ class GraphMailAutoConfiguration { @ConditionalOnMissingBean(GraphMailHttpSecurityConfigurer::class) fun graphMailHttpSecurityConfigurer(): GraphMailHttpSecurityConfigurer = GraphMailHttpSecurityConfigurer() - private fun configureJackson(restTemplate: RestTemplate, objectMapper: ObjectMapper) { + private fun configureJackson( + restTemplate: RestTemplate, + objectMapper: ObjectMapper, + ) { restTemplate.messageConverters.removeIf { it is MappingJackson2HttpMessageConverter } restTemplate.messageConverters.add(0, MappingJackson2HttpMessageConverter(objectMapper)) } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClient.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClient.kt index 7d78a35..574490e 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClient.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClient.kt @@ -11,7 +11,6 @@ package com.ritense.valtimoplugins.graphmail * (Mockito, MockK, Spring Test) without coupling to the HTTP implementation. */ interface GraphMailClient { - fun sendMail( tenantId: String, clientId: String, @@ -31,5 +30,8 @@ interface GraphMailClient { * Invalidate the cached token for one tenant/client pair. * If both args are null the entire cache is flushed (rare — global compromise). */ - fun invalidateCache(tenantId: String? = null, clientId: String? = null) + fun invalidateCache( + tenantId: String? = null, + clientId: String? = null, + ) } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClientImpl.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClientImpl.kt index 92c9e54..181029a 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClientImpl.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailClientImpl.kt @@ -24,19 +24,23 @@ private const val TOKEN_EXPIRY_BUFFER_SECONDS = 60L private const val MAX_RETRIES = 5 private const val INITIAL_BACKOFF_MS = 500L private const val BACKOFF_MULTIPLIER = 2.0 + // Cap the Retry-After header to limit per-sleep blocking time on the job-executor thread. // 15s × 5 retries = 75s worst case per send; wall-clock caps (30s/120s) apply on top. private const val MAX_RETRY_AFTER_SECONDS = 15L private const val TOKEN_MAX_RETRIES = 3 + // Hard wall-clock cap for the entire send operation (including all retries and backoff sleeps). // Ensures a 429 storm cannot hold an Operaton BPM job-executor thread longer than this limit. private const val MAX_SEND_WALL_CLOCK_MS = 30_000L + // Longer deadline for the draft+upload flow — large uploads can take tens of seconds. private const val MAX_DRAFT_SEND_WALL_CLOCK_MS = 120_000L private const val CHUNK_MAX_RETRIES = 3 + // Default token cache capacity — override via constructor parameter. // Increase if the deployment manages more than 64 distinct Entra app registrations. -private const val DEFAULT_maxCachedTokens = 64 +private const val DEFAULT_MAX_CACHED_TOKENS = 64 // NOTE (threading): retry backoff uses Thread.sleep(), which blocks the calling thread. // In Operaton BPM (V13), SERVICE_TASK actions run on the job-executor thread pool. @@ -59,17 +63,23 @@ class GraphMailClientImpl( private val restClient: RestClient, private val tokenBaseUrl: String = "https://login.microsoftonline.com", private val graphBaseUrl: String = "https://graph.microsoft.com", - private val maxCachedTokens: Int = DEFAULT_maxCachedTokens, + private val maxCachedTokens: Int = DEFAULT_MAX_CACHED_TOKENS, ) : GraphMailClient { - private val logger = LoggerFactory.getLogger(GraphMailClientImpl::class.java) - private data class CachedToken(val token: String, val expiresAt: Instant, val createdAt: Instant) + private data class CachedToken( + val token: String, + val expiresAt: Instant, + val createdAt: Instant, + ) private val tokenCache = ConcurrentHashMap() private val keyLocks = ConcurrentHashMap() - private fun cacheKey(tenantId: String, clientId: String) = "$tenantId:$clientId" + private fun cacheKey( + tenantId: String, + clientId: String, + ) = "$tenantId:$clientId" private fun lockFor(key: String): ReentrantLock = keyLocks.computeIfAbsent(key) { ReentrantLock() }.also { @@ -83,7 +93,11 @@ class GraphMailClientImpl( } } - internal fun getAccessToken(tenantId: String, clientId: String, clientSecret: String): String { + internal fun getAccessToken( + tenantId: String, + clientId: String, + clientSecret: String, + ): String { require(tenantId.isNotBlank()) { "tenantId must not be blank" } require(clientId.isNotBlank()) { "clientId must not be blank" } require(clientSecret.isNotBlank()) { "clientSecret must not be blank" } @@ -110,7 +124,10 @@ class GraphMailClientImpl( } } - override fun invalidateCache(tenantId: String?, clientId: String?) { + override fun invalidateCache( + tenantId: String?, + clientId: String?, + ) { when { tenantId != null && clientId != null -> { val removed = tokenCache.remove(cacheKey(tenantId, clientId)) @@ -121,26 +138,35 @@ class GraphMailClientImpl( tokenCache.clear() logger.warn("Token cache fully cleared ({} entries)", count) } - else -> logger.warn( - "invalidateCache called with partial selector — ignored (tenantId={}, clientId={})", - tenantId != null, clientId != null - ) + else -> + logger.warn( + "invalidateCache called with partial selector — ignored (tenantId={}, clientId={})", + tenantId != null, + clientId != null, + ) } } - private fun fetchAndCacheToken(tenantId: String, clientId: String, clientSecret: String, key: String): String { - val url = UriComponentsBuilder - .fromUriString("$tokenBaseUrl/{tenantId}/oauth2/v2.0/token") - .build() - .expand(tenantId) - .toUriString() - - val form = LinkedMultiValueMap().apply { - add("grant_type", "client_credentials") - add("client_id", clientId) - add("client_secret", clientSecret) - add("scope", GRAPH_SCOPE) - } + private fun fetchAndCacheToken( + tenantId: String, + clientId: String, + clientSecret: String, + key: String, + ): String { + val url = + UriComponentsBuilder + .fromUriString("$tokenBaseUrl/{tenantId}/oauth2/v2.0/token") + .build() + .expand(tenantId) + .toUriString() + + val form = + LinkedMultiValueMap().apply { + add("grant_type", "client_credentials") + add("client_id", clientId) + add("client_secret", clientSecret) + add("scope", GRAPH_SCOPE) + } val response = postTokenWithRetry(url, form, tenantId) @@ -154,13 +180,18 @@ class GraphMailClientImpl( return response.accessToken } - private fun postTokenWithRetry(url: String, form: LinkedMultiValueMap, tenantId: String): TokenResponse { + private fun postTokenWithRetry( + url: String, + form: LinkedMultiValueMap, + tenantId: String, + ): TokenResponse { var attempt = 0 var backoffMs = INITIAL_BACKOFF_MS while (true) { attempt++ try { - return restClient.post() + return restClient + .post() .uri(url) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(form) @@ -181,19 +212,33 @@ class GraphMailClientImpl( throw GraphMailException("Azure Entra unavailable (${ex.statusCode})") } val delay = backoffMs + Random.nextLong(0, (backoffMs / 2).coerceAtLeast(1)) - logger.warn("Token request {} — attempt {}/{}, retrying in {}ms", - ex.statusCode, attempt, TOKEN_MAX_RETRIES, delay) + logger.warn( + "Token request {} — attempt {}/{}, retrying in {}ms", + ex.statusCode, + attempt, + TOKEN_MAX_RETRIES, + delay, + ) Thread.sleep(delay) backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong() } catch (ex: ResourceAccessException) { if (attempt >= TOKEN_MAX_RETRIES) { - logger.warn("Token request timed out for tenant [{}] after {} attempts: {}", - tenantId, attempt, ex.message) + logger.warn( + "Token request timed out for tenant [{}] after {} attempts: {}", + tenantId, + attempt, + ex.message, + ) throw GraphMailException("Could not reach Azure Entra (timeout or network error): ${ex.message}") } val delay = backoffMs + Random.nextLong(0, (backoffMs / 2).coerceAtLeast(1)) - logger.warn("Token request network error — attempt {}/{}, retrying in {}ms: {}", - attempt, TOKEN_MAX_RETRIES, delay, ex.message) + logger.warn( + "Token request network error — attempt {}/{}, retrying in {}ms: {}", + attempt, + TOKEN_MAX_RETRIES, + delay, + ex.message, + ) Thread.sleep(delay) backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong() } @@ -203,7 +248,10 @@ class GraphMailClientImpl( private fun evictIfFull() { if (tokenCache.size < maxCachedTokens) return // Evict the oldest entry by createdAt — bounded scan, only runs at capacity. - tokenCache.entries.minByOrNull { it.value.createdAt }?.key?.let { tokenCache.remove(it) } + tokenCache.entries + .minByOrNull { it.value.createdAt } + ?.key + ?.let { tokenCache.remove(it) } } // Retry-After can be seconds ("120") or an HTTP date ("Wed, 21 Oct 2025 07:28:00 GMT"). @@ -236,25 +284,53 @@ class GraphMailClientImpl( require(toRecipients.isNotEmpty()) { "At least one recipient is required" } val recipientCount = toRecipients.size - logger.info("Sending email — recipients: {}, mailbox: '{}'", - recipientCount, maskEmail(senderMailbox)) + logger.info( + "Sending email — recipients: {}, mailbox: '{}'", + recipientCount, + maskEmail(senderMailbox), + ) - val useDraftFlow = attachments.any { it.sizeBytes > INLINE_ATTACHMENT_THRESHOLD_BYTES } - || attachments.sumOf { it.sizeBytes } > INLINE_ATTACHMENT_THRESHOLD_BYTES + val useDraftFlow = + attachments.any { it.sizeBytes > INLINE_ATTACHMENT_THRESHOLD_BYTES } || + attachments.sumOf { it.sizeBytes } > INLINE_ATTACHMENT_THRESHOLD_BYTES if (useDraftFlow) { - logger.debug("Using draft+upload flow — {} attachment(s), total {} bytes", - attachments.size, attachments.sumOf { it.sizeBytes }) - sendViaDraftAndUpload(tenantId, clientId, clientSecret, senderMailbox, - toRecipients, ccRecipients, bccRecipients, replyToRecipients, - subject, bodyHtml, attachments, saveToSentItems) + logger.debug( + "Using draft+upload flow — {} attachment(s), total {} bytes", + attachments.size, + attachments.sumOf { it.sizeBytes }, + ) + sendViaDraftAndUpload( + tenantId, + clientId, + clientSecret, + senderMailbox, + toRecipients, + ccRecipients, + bccRecipients, + replyToRecipients, + subject, + bodyHtml, + attachments, + saveToSentItems, + ) } else { - val sendMailUri: URI = UriComponentsBuilder - .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/sendMail") - .buildAndExpand(senderMailbox) - .toUri() - val payload = buildInlinePayload(subject, bodyHtml, toRecipients, ccRecipients, - bccRecipients, replyToRecipients, attachments, saveToSentItems) + val sendMailUri: URI = + UriComponentsBuilder + .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/sendMail") + .buildAndExpand(senderMailbox) + .toUri() + val payload = + buildInlinePayload( + subject, + bodyHtml, + toRecipients, + ccRecipients, + bccRecipients, + replyToRecipients, + attachments, + saveToSentItems, + ) sendWithRefreshAndRetry(tenantId, clientId, clientSecret, sendMailUri, payload, senderMailbox) } logger.info("Email sent successfully — recipients: {}", recipientCount) @@ -270,23 +346,25 @@ class GraphMailClientImpl( attachments: List, saveToSentItems: Boolean, ): SendMailRequest { - val inlineAttachments = attachments.map { a -> - GraphAttachment( - name = a.name, - contentType = a.contentType, - contentBytes = Base64.getEncoder().encodeToString(a.rawBytes), - ) - } + val inlineAttachments = + attachments.map { a -> + GraphAttachment( + name = a.name, + contentType = a.contentType, + contentBytes = Base64.getEncoder().encodeToString(a.rawBytes), + ) + } return SendMailRequest( - message = GraphMessage( - subject = subject, - body = GraphBody(contentType = GRAPH_BODY_CONTENT_TYPE_HTML, content = bodyHtml), - toRecipients = toRecipients, - ccRecipients = ccRecipients, - bccRecipients = bccRecipients, - replyTo = replyToRecipients, - attachments = inlineAttachments, - ), + message = + GraphMessage( + subject = subject, + body = GraphBody(contentType = GRAPH_BODY_CONTENT_TYPE_HTML, content = bodyHtml), + toRecipients = toRecipients, + ccRecipients = ccRecipients, + bccRecipients = bccRecipients, + replyTo = replyToRecipients, + attachments = inlineAttachments, + ), saveToSentItems = saveToSentItems, ) } @@ -307,21 +385,30 @@ class GraphMailClientImpl( ) { val deadline = System.currentTimeMillis() + MAX_DRAFT_SEND_WALL_CLOCK_MS - val draftMessage = GraphMessage( - subject = subject, - body = GraphBody(contentType = GRAPH_BODY_CONTENT_TYPE_HTML, content = bodyHtml), - toRecipients = toRecipients, - ccRecipients = ccRecipients, - bccRecipients = bccRecipients, - replyTo = replyToRecipients, - ) + val draftMessage = + GraphMessage( + subject = subject, + body = GraphBody(contentType = GRAPH_BODY_CONTENT_TYPE_HTML, content = bodyHtml), + toRecipients = toRecipients, + ccRecipients = ccRecipients, + bccRecipients = bccRecipients, + replyTo = replyToRecipients, + ) val draftId = createDraftWithRetry(tenantId, clientId, clientSecret, senderMailbox, draftMessage, deadline) logger.debug("Draft created id={}", draftId) try { for (attachment in attachments) { - val uploadUrl = createUploadSession(tenantId, clientId, clientSecret, - senderMailbox, draftId, attachment, deadline) + val uploadUrl = + createUploadSession( + tenantId, + clientId, + clientSecret, + senderMailbox, + draftId, + attachment, + deadline, + ) uploadInChunks(uploadUrl, attachment, deadline) logger.debug("Attachment uploaded: name='{}' size={}", attachment.name, attachment.sizeBytes) } @@ -340,12 +427,14 @@ class GraphMailClientImpl( draftId: String, ) { try { - val uri: URI = UriComponentsBuilder - .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages/{id}") - .buildAndExpand(senderMailbox, draftId) - .toUri() + val uri: URI = + UriComponentsBuilder + .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages/{id}") + .buildAndExpand(senderMailbox, draftId) + .toUri() val token = getAccessToken(tenantId, clientId, clientSecret) - restClient.delete() + restClient + .delete() .uri(uri) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .retrieve() @@ -364,22 +453,25 @@ class GraphMailClientImpl( message: GraphMessage, deadline: Long, ): String { - val uri: URI = UriComponentsBuilder - .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages") - .buildAndExpand(senderMailbox) - .toUri() + val uri: URI = + UriComponentsBuilder + .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages") + .buildAndExpand(senderMailbox) + .toUri() var tokenRefreshed = false var attempt = 0 var backoffMs = INITIAL_BACKOFF_MS while (true) { - if (System.currentTimeMillis() > deadline) + if (System.currentTimeMillis() > deadline) { throw GraphMailException("Draft creation timed out after ${MAX_DRAFT_SEND_WALL_CLOCK_MS}ms") + } attempt++ val token = getAccessToken(tenantId, clientId, clientSecret) try { - return restClient.post() + return restClient + .post() .uri(uri) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .contentType(MediaType.APPLICATION_JSON) @@ -391,29 +483,40 @@ class GraphMailClientImpl( } catch (ex: HttpClientErrorException) { when (ex.statusCode.value()) { 401 -> { - if (tokenRefreshed) throw GraphMailTokenExpiredException( - "Token rejected when creating draft (401) — check Mail.ReadWrite permission", ex) + if (tokenRefreshed) { + throw GraphMailTokenExpiredException( + "Token rejected when creating draft (401) — check Mail.ReadWrite permission", + ex, + ) + } invalidateCache(tenantId, clientId) tokenRefreshed = true attempt-- } 429 -> { - if (attempt >= MAX_RETRIES) + if (attempt >= MAX_RETRIES) { throw GraphMailException("Rate limited creating draft after $MAX_RETRIES attempts", ex) - val wait = (parseRetryAfter(ex.responseHeaders?.getFirst("Retry-After")) - .coerceAtMost(MAX_RETRY_AFTER_SECONDS) * 1000) - .coerceAtMost(deadline - System.currentTimeMillis()) + } + val wait = + ( + parseRetryAfter(ex.responseHeaders?.getFirst("Retry-After")) + .coerceAtMost(MAX_RETRY_AFTER_SECONDS) * 1000 + ).coerceAtMost(deadline - System.currentTimeMillis()) if (wait > 0) Thread.sleep(wait) } else -> throw GraphMailException( - "Graph API rejected draft creation (${ex.statusCode})", ex, - statusCode = ex.statusCode.value()) + "Graph API rejected draft creation (${ex.statusCode})", + ex, + statusCode = ex.statusCode.value(), + ) } } catch (ex: HttpServerErrorException) { - if (attempt >= MAX_RETRIES) + if (attempt >= MAX_RETRIES) { throw GraphMailException("Graph API unavailable creating draft after $MAX_RETRIES attempts", ex) - val delay = (backoffMs + Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1))) - .coerceAtMost(deadline - System.currentTimeMillis()) + } + val delay = + (backoffMs + Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1))) + .coerceAtMost(deadline - System.currentTimeMillis()) if (delay > 0) Thread.sleep(delay) backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong() } @@ -429,26 +532,31 @@ class GraphMailClientImpl( attachment: ResolvedAttachment, deadline: Long, ): String { - val uri: URI = UriComponentsBuilder - .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages/{id}/attachments/createUploadSession") - .buildAndExpand(senderMailbox, draftId) - .toUri() - - val body = CreateUploadSessionRequest( - attachmentItem = UploadAttachmentItem( - name = attachment.name, - size = attachment.sizeBytes, - contentType = attachment.contentType, + val uri: URI = + UriComponentsBuilder + .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages/{id}/attachments/createUploadSession") + .buildAndExpand(senderMailbox, draftId) + .toUri() + + val body = + CreateUploadSessionRequest( + attachmentItem = + UploadAttachmentItem( + name = attachment.name, + size = attachment.sizeBytes, + contentType = attachment.contentType, + ), ) - ) - if (System.currentTimeMillis() > deadline) + if (System.currentTimeMillis() > deadline) { throw GraphMailException("Upload session creation timed out after ${MAX_DRAFT_SEND_WALL_CLOCK_MS}ms") + } var tokenRefreshed = false fun doPost(token: String): String = - restClient.post() + restClient + .post() .uri(uri) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .contentType(MediaType.APPLICATION_JSON) @@ -458,42 +566,72 @@ class GraphMailClientImpl( ?.uploadUrl ?: throw GraphMailException("Empty uploadUrl in upload session response") - val uploadUrl = try { - doPost(getAccessToken(tenantId, clientId, clientSecret)) - } catch (ex: HttpClientErrorException) { - if (ex.statusCode.value() == 401 && !tokenRefreshed) { - tokenRefreshed = true - invalidateCache(tenantId, clientId) + val uploadUrl = + try { doPost(getAccessToken(tenantId, clientId, clientSecret)) - } else { - throw GraphMailException( - "Graph API rejected upload session creation (${ex.statusCode})", ex, - statusCode = ex.statusCode.value()) + } catch (ex: HttpClientErrorException) { + if (ex.statusCode.value() == 401 && !tokenRefreshed) { + tokenRefreshed = true + invalidateCache(tenantId, clientId) + doPost(getAccessToken(tenantId, clientId, clientSecret)) + } else { + throw GraphMailException( + "Graph API rejected upload session creation (${ex.statusCode})", + ex, + statusCode = ex.statusCode.value(), + ) + } } - } // Derive expected scheme+host from graphBaseUrl so WireMock tests (http://localhost) // pass while production rejects any non-Microsoft https:// domain. - val expectedScheme = runCatching { java.net.URI.create(graphBaseUrl).scheme }.getOrElse { "https" } - val expectedHost = runCatching { java.net.URI.create(graphBaseUrl).host }.getOrElse { "graph.microsoft.com" } - val actualScheme = runCatching { java.net.URI.create(uploadUrl).scheme }.getOrNull() - val actualHost = runCatching { java.net.URI.create(uploadUrl).host }.getOrNull() + val expectedScheme = + runCatching { + java.net.URI + .create(graphBaseUrl) + .scheme + }.getOrElse { "https" } + val expectedHost = + runCatching { + java.net.URI + .create(graphBaseUrl) + .host + }.getOrElse { "graph.microsoft.com" } + val actualScheme = + runCatching { + java.net.URI + .create(uploadUrl) + .scheme + }.getOrNull() + val actualHost = + runCatching { + java.net.URI + .create(uploadUrl) + .host + }.getOrNull() val microsoftHosts = listOf(".microsoft.com", ".office.com", ".office.net", ".office365.com") - require(actualScheme == expectedScheme && - (actualHost == expectedHost || microsoftHosts.any { actualHost?.endsWith(it) == true })) { + require( + actualScheme == expectedScheme && + (actualHost == expectedHost || microsoftHosts.any { actualHost?.endsWith(it) == true }), + ) { "Upload URL from Graph API failed domain validation" } return uploadUrl } - private fun uploadInChunks(uploadUrl: String, attachment: ResolvedAttachment, deadline: Long) { + private fun uploadInChunks( + uploadUrl: String, + attachment: ResolvedAttachment, + deadline: Long, + ) { val bytes = attachment.rawBytes val total = bytes.size.toLong() var offset = 0L while (offset < total) { - if (System.currentTimeMillis() > deadline) + if (System.currentTimeMillis() > deadline) { throw GraphMailException("Attachment upload timed out after ${MAX_DRAFT_SEND_WALL_CLOCK_MS}ms") + } val end = minOf(offset + UPLOAD_CHUNK_BYTES - 1, total - 1) val chunkLen = (end - offset + 1).toInt() @@ -504,7 +642,8 @@ class GraphMailClientImpl( while (!success) { chunkAttempt++ try { - restClient.put() + restClient + .put() .uri(URI.create(uploadUrl)) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(chunkLen.toLong()) @@ -515,21 +654,31 @@ class GraphMailClientImpl( success = true } catch (ex: HttpClientErrorException) { throw GraphMailException( - "Chunk upload rejected (${ex.statusCode}) at offset $offset", ex, - statusCode = ex.statusCode.value()) + "Chunk upload rejected (${ex.statusCode}) at offset $offset", + ex, + statusCode = ex.statusCode.value(), + ) } catch (ex: HttpServerErrorException) { - if (chunkAttempt >= CHUNK_MAX_RETRIES) + if (chunkAttempt >= CHUNK_MAX_RETRIES) { throw GraphMailException( - "Chunk upload failed after $CHUNK_MAX_RETRIES attempts at offset $offset", ex) - val delay = (500L * (1 shl (chunkAttempt - 1))) - .coerceAtMost(deadline - System.currentTimeMillis()) + "Chunk upload failed after $CHUNK_MAX_RETRIES attempts at offset $offset", + ex, + ) + } + val delay = + (500L * (1 shl (chunkAttempt - 1))) + .coerceAtMost(deadline - System.currentTimeMillis()) if (delay > 0) Thread.sleep(delay) } catch (ex: ResourceAccessException) { - if (chunkAttempt >= CHUNK_MAX_RETRIES) + if (chunkAttempt >= CHUNK_MAX_RETRIES) { throw GraphMailException( - "Chunk upload unreachable after $CHUNK_MAX_RETRIES attempts at offset $offset", ex) - val delay = (500L * (1 shl (chunkAttempt - 1))) - .coerceAtMost(deadline - System.currentTimeMillis()) + "Chunk upload unreachable after $CHUNK_MAX_RETRIES attempts at offset $offset", + ex, + ) + } + val delay = + (500L * (1 shl (chunkAttempt - 1))) + .coerceAtMost(deadline - System.currentTimeMillis()) if (delay > 0) Thread.sleep(delay) } } @@ -545,22 +694,25 @@ class GraphMailClientImpl( draftId: String, deadline: Long, ) { - val uri: URI = UriComponentsBuilder - .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages/{id}/send") - .buildAndExpand(senderMailbox, draftId) - .toUri() + val uri: URI = + UriComponentsBuilder + .fromUriString("$graphBaseUrl/v1.0/users/{mailbox}/messages/{id}/send") + .buildAndExpand(senderMailbox, draftId) + .toUri() var tokenRefreshed = false var attempt = 0 var backoffMs = INITIAL_BACKOFF_MS while (true) { - if (System.currentTimeMillis() > deadline) + if (System.currentTimeMillis() > deadline) { throw GraphMailException("Draft send timed out after ${MAX_DRAFT_SEND_WALL_CLOCK_MS}ms") + } attempt++ val token = getAccessToken(tenantId, clientId, clientSecret) try { - restClient.post() + restClient + .post() .uri(uri) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .contentLength(0) @@ -570,29 +722,40 @@ class GraphMailClientImpl( } catch (ex: HttpClientErrorException) { when (ex.statusCode.value()) { 401 -> { - if (tokenRefreshed) throw GraphMailTokenExpiredException( - "Token rejected sending draft (401) — check Mail.Send permission", ex) + if (tokenRefreshed) { + throw GraphMailTokenExpiredException( + "Token rejected sending draft (401) — check Mail.Send permission", + ex, + ) + } invalidateCache(tenantId, clientId) tokenRefreshed = true attempt-- } 429 -> { - if (attempt >= MAX_RETRIES) + if (attempt >= MAX_RETRIES) { throw GraphMailException("Rate limited sending draft after $MAX_RETRIES attempts", ex) - val wait = (parseRetryAfter(ex.responseHeaders?.getFirst("Retry-After")) - .coerceAtMost(MAX_RETRY_AFTER_SECONDS) * 1000) - .coerceAtMost(deadline - System.currentTimeMillis()) + } + val wait = + ( + parseRetryAfter(ex.responseHeaders?.getFirst("Retry-After")) + .coerceAtMost(MAX_RETRY_AFTER_SECONDS) * 1000 + ).coerceAtMost(deadline - System.currentTimeMillis()) if (wait > 0) Thread.sleep(wait) } else -> throw GraphMailException( - "Graph API rejected draft send (${ex.statusCode})", ex, - statusCode = ex.statusCode.value()) + "Graph API rejected draft send (${ex.statusCode})", + ex, + statusCode = ex.statusCode.value(), + ) } } catch (ex: HttpServerErrorException) { - if (attempt >= MAX_RETRIES) + if (attempt >= MAX_RETRIES) { throw GraphMailException("Graph API unavailable sending draft after $MAX_RETRIES attempts", ex) - val delay = (backoffMs + Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1))) - .coerceAtMost(deadline - System.currentTimeMillis()) + } + val delay = + (backoffMs + Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1))) + .coerceAtMost(deadline - System.currentTimeMillis()) if (delay > 0) Thread.sleep(delay) backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong() } @@ -615,13 +778,14 @@ class GraphMailClientImpl( while (true) { if (System.currentTimeMillis() > callDeadline) { throw GraphMailException( - "Email send timed out — total wall-clock limit of ${MAX_SEND_WALL_CLOCK_MS}ms exceeded" + "Email send timed out — total wall-clock limit of ${MAX_SEND_WALL_CLOCK_MS}ms exceeded", ) } attempt++ val token = getAccessToken(tenantId, clientId, clientSecret) try { - restClient.post() + restClient + .post() .uri(url) .header(HttpHeaders.AUTHORIZATION, "Bearer $token") .contentType(MediaType.APPLICATION_JSON) @@ -635,29 +799,39 @@ class GraphMailClientImpl( if (tokenRefreshed) { logger.error("401 Unauthorized after fresh token — Mail.Send permission likely missing") throw GraphMailTokenExpiredException( - "Token rejected by Graph API (401) even after refresh — check Mail.Send permission", ex + "Token rejected by Graph API (401) even after refresh — check Mail.Send permission", + ex, ) } - logger.warn("401 Unauthorized — invalidating cached token for [{}:***] and retrying once", - tenantId) + logger.warn( + "401 Unauthorized — invalidating cached token for [{}:***] and retrying once", + tenantId, + ) invalidateCache(tenantId, clientId) tokenRefreshed = true attempt-- // Don't burn a retry attempt on the refresh. } 429 -> { - val retryAfter = parseRetryAfter(ex.responseHeaders?.getFirst("Retry-After")) - .coerceAtMost(MAX_RETRY_AFTER_SECONDS) + val retryAfter = + parseRetryAfter(ex.responseHeaders?.getFirst("Retry-After")) + .coerceAtMost(MAX_RETRY_AFTER_SECONDS) if (attempt >= MAX_RETRIES) { throw GraphMailException("Rate limited after $MAX_RETRIES attempts (429)", ex) } val sleepMs = minOf(retryAfter * 1000, callDeadline - System.currentTimeMillis()) - logger.warn("429 Rate limited — waiting {}ms (Retry-After={}s, wall-clock cap)", - sleepMs, retryAfter) + logger.warn( + "429 Rate limited — waiting {}ms (Retry-After={}s, wall-clock cap)", + sleepMs, + retryAfter, + ) if (sleepMs > 0) Thread.sleep(sleepMs) } else -> { - logger.error("Graph API rejected email ({}): mailbox='{}'", - ex.statusCode, maskEmail(mailbox)) + logger.error( + "Graph API rejected email ({}): mailbox='{}'", + ex.statusCode, + maskEmail(mailbox), + ) throw GraphMailException( "Graph API rejected email (${ex.statusCode}): check mailbox and Mail.Send permission", ex, @@ -669,14 +843,20 @@ class GraphMailClientImpl( if (attempt >= MAX_RETRIES) { logger.error("Graph API unavailable after {} attempts ({})", MAX_RETRIES, ex.statusCode) throw GraphMailException( - "Graph API unavailable after $MAX_RETRIES attempts (${ex.statusCode})", ex + "Graph API unavailable after $MAX_RETRIES attempts (${ex.statusCode})", + ex, ) } // Jitter scales with backoff (up to 20% of current backoff) — better thundering-herd defense. val jitter = Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1)) val delayMs = minOf(backoffMs + jitter, callDeadline - System.currentTimeMillis()) - logger.warn("Graph API {} — attempt {}/{}, waiting {}ms", - ex.statusCode, attempt, MAX_RETRIES, delayMs) + logger.warn( + "Graph API {} — attempt {}/{}, waiting {}ms", + ex.statusCode, + attempt, + MAX_RETRIES, + delayMs, + ) if (delayMs > 0) Thread.sleep(delayMs) backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong() } catch (ex: ResourceAccessException) { @@ -684,12 +864,17 @@ class GraphMailClientImpl( logger.error("Graph API unreachable after {} attempts: {}", MAX_RETRIES, ex.message) throw GraphMailException("Graph API unreachable after $MAX_RETRIES attempts: ${ex.message}", ex) } - val delayMs = minOf( - backoffMs + Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1)), - callDeadline - System.currentTimeMillis() + val delayMs = + minOf( + backoffMs + Random.nextLong(0, (backoffMs / 5).coerceAtLeast(1)), + callDeadline - System.currentTimeMillis(), + ) + logger.warn( + "Graph API network error — attempt {}/{}, retrying in {}ms", + attempt, + MAX_RETRIES, + delayMs, ) - logger.warn("Graph API network error — attempt {}/{}, retrying in {}ms", - attempt, MAX_RETRIES, delayMs) if (delayMs > 0) Thread.sleep(delayMs) backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong() } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailHttpSecurityConfigurer.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailHttpSecurityConfigurer.kt index 1c6493d..758768b 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailHttpSecurityConfigurer.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailHttpSecurityConfigurer.kt @@ -5,10 +5,10 @@ import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.builders.HttpSecurity class GraphMailHttpSecurityConfigurer : HttpSecurityConfigurer { - override fun configure(http: HttpSecurity) { http.authorizeHttpRequests { requests -> - requests.requestMatchers(HttpMethod.POST, "/api/v1/plugin/entra/test-send") + requests + .requestMatchers(HttpMethod.POST, "/api/v1/plugin/entra/test-send") .hasAuthority("ROLE_ADMIN") } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailPlugin.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailPlugin.kt index a0277d5..f79bbe7 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailPlugin.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/graphmail/GraphMailPlugin.kt @@ -29,20 +29,24 @@ private const val MAX_BODY_CONTENT_BYTES = 5 * 1_048_576 // inline `style` attributes for layout.