This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new b7704c6db fix(alert): bound SMTP sockets and stamp outbox rows in UTC 
(#6121)
b7704c6db is described below

commit b7704c6dbfed0f59c3a052be65c685c3c75da2af
Author: wizcraft_kris <[email protected]>
AuthorDate: Mon Oct 12 16:14:13 2026 +0800

    fix(alert): bound SMTP sockets and stamp outbox rows in UTC (#6121)
    
    Two fixes to the notification outbox, whose email path had no time budget 
and whose bookkeeping timestamps were written by a clock the service does not 
control.
    
    - #6072 JavaMail's three socket timeouts default to infinite, and 
`NotificationOutboxService.sendEmail` (`:477-489`) calls `sender.send(message)` 
directly from the `@Scheduled` dispatcher at `:306` and the cleanup job at 
`:325`; the repo configures no `spring.task.scheduling` pool and defines no 
custom `TaskScheduler` bean, so Boot's default pool size of 1 means an SMTP 
relay that accepts the connection and never sends a 220 banner pins the 
backend's only scheduling thread forever — s [...]
    - #6121 `db/schema.sql:402-403` lets the database session clock fill 
`gmt_create` and `gmt_modified` (`DEFAULT CURRENT_TIMESTAMP` / `ON UPDATE 
CURRENT_TIMESTAMP`) while the retention cutoff is a JVM UTC instant 
(`NotificationOutboxService.java:340`, consumed by 
`RmqAlertNotificationOutboxMapper.java:27-29`), and nothing forces the two 
clocks to agree — `application.yml:11` defaults to `serverTimezone=UTC` while 
`deploy/docker-compose.yml:86` ships `Asia/Shanghai` — so terminal deliver [...]
    
    Tests: a new `SmtpTimeoutTest` pins the shipped 5000/10000/10000 values, 
proves all three properties are deployment-overridable, and reproduces the 
wedge against a real loopback `ServerSocket` that accepts and then stays 
silent, asserting a `MailSendException` with a `SocketTimeoutException` root 
cause from a send running on a daemon thread that the test wakes by closing the 
accepted socket; `NotificationOutboxServiceTest` adds four cases and 
`NotificationOutboxMapperIntegrationTest`  [...]
    
    Folded: #6072 was closed as folded into this commit (same author).
---
 docs/studio-native-alerting-design.md              |   8 +
 .../ops/alert/NotificationOutboxService.java       |  14 +-
 .../entity/RmqAlertNotificationOutbox.java         |   1 +
 .../mapper/RmqAlertNotificationOutboxMapper.java   |   7 +-
 server/src/main/resources/application.yml          |   3 +
 .../NotificationOutboxMapperIntegrationTest.java   |  28 +++
 .../ops/alert/NotificationOutboxServiceTest.java   | 147 +++++++++++++++
 .../rocketmq/studio/ops/alert/SmtpTimeoutTest.java | 198 +++++++++++++++++++++
 8 files changed, 402 insertions(+), 4 deletions(-)

diff --git a/docs/studio-native-alerting-design.md 
b/docs/studio-native-alerting-design.md
index 7b1f75d53..e3242fa8d 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -268,6 +268,14 @@ Email delivery uses Spring's standard SMTP configuration. 
Configure `STUDIO_ALER
 `STUDIO_ALERTING_SMTP_AUTH`, and `STUDIO_ALERTING_SMTP_STARTTLS`; recipient 
addresses are configured in
 General Settings. Missing SMTP configuration leaves the outbox row retryable 
and records the delivery failure.
 
+Socket timeouts are bounded by default and tunable through 
`STUDIO_ALERTING_SMTP_CONNECTION_TIMEOUT` (5000 ms),
+`STUDIO_ALERTING_SMTP_READ_TIMEOUT` (10000 ms) and 
`STUDIO_ALERTING_SMTP_WRITE_TIMEOUT` (10000 ms). They matter
+because `@Scheduled` work runs on the single scheduler thread Spring Boot uses 
by default (`spring.task.scheduling.pool.size`
+is not raised in `application.yml`), and that thread also drives metrics 
collection, authentication session cleanup,
+query-history pruning, conversation cleanup and outbox retention. An 
unreachable SMTP peer that never resets the
+connection would otherwise hold it for as long as the OS socket timeout 
allows, freezing every other scheduled task
+with it. A timed-out send leaves the outbox row retryable, which is the same 
path a missing configuration takes.
+
 ```text
 PENDING -> SENDING -> DELIVERED
                   -> RETRY_WAIT -> FAILED
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
index 955eb3178..7e16786ff 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
@@ -197,7 +197,12 @@ public class NotificationOutboxService {
             row.setStatus(NotificationOutboxStatus.PENDING.name());
             row.setAttemptCount(0);
             
row.setMessageContent(AlertNotificationTemplate.render(rule.getNotificationTemplate(),
 alert, rule));
-            row.setNextAttemptAt(silenceEndsAt == null ? utcNow() : 
silenceEndsAt);
+            LocalDateTime now = utcNow();
+            row.setNextAttemptAt(silenceEndsAt == null ? now : silenceEndsAt);
+            // The retention cutoff is computed in UTC; leaving these to the 
database defaults would
+            // stamp them with the session clock (Asia/Shanghai in the compose 
deploy) instead.
+            row.setGmtCreate(now);
+            row.setGmtModified(now);
             mapper.insert(row);
         }
     }
@@ -251,7 +256,8 @@ public class NotificationOutboxService {
         int updated = mapper.update(null, new 
UpdateWrapper<RmqAlertNotificationOutbox>()
                 .set("status", 
NotificationOutboxStatus.PENDING.name()).set("attempt_count", 0)
                 .set("next_attempt_at", now).set("sending_started_at", 
null).set("claim_token", null)
-                .set("last_error", null).eq("id", deliveryId).eq("status", 
NotificationOutboxStatus.FAILED.name()));
+                .set("last_error", null).set("gmt_modified", now)
+                .eq("id", deliveryId).eq("status", 
NotificationOutboxStatus.FAILED.name()));
         if (updated != 1) {
             throw new 
org.apache.rocketmq.studio.common.exception.BusinessException(400,
                     "Only failed notification deliveries can be retried");
@@ -567,6 +573,10 @@ public class NotificationOutboxService {
 
     private boolean updateClaimed(RmqAlertNotificationOutbox row, String 
claimToken,
             UpdateWrapper<RmqAlertNotificationOutbox> updates) {
+        // Every claimed state change stamps gmt_modified from the same UTC 
clock as the retention
+        // cutoff. Without this the ON UPDATE CURRENT_TIMESTAMP default 
rewrites it in the database
+        // session's timezone and the purge compares two clocks.
+        updates.set("gmt_modified", utcNow());
         return mapper.update(null, updates.eq("id", row.getId()).eq("status", 
NotificationOutboxStatus.SENDING.name())
                 .eq("claim_token", claimToken)) == 1;
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java
index 3484bc62f..5d984819f 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqAlertNotificationOutbox.java
@@ -27,5 +27,6 @@ public class RmqAlertNotificationOutbox {
     private String lastError;
     private String messageContent;
     private LocalDateTime deliveredAt;
+    private LocalDateTime gmtCreate;
     private LocalDateTime gmtModified;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
index 6243da06c..c59e0a24d 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/persistence/mapper/RmqAlertNotificationOutboxMapper.java
@@ -38,14 +38,17 @@ public interface RmqAlertNotificationOutboxMapper extends 
BaseMapper<RmqAlertNot
             @Param("staleBefore") LocalDateTime staleBefore, @Param("limit") 
int limit);
 
     @Update("UPDATE rmq_alert_notification_outbox SET status = 'SENDING', 
sending_started_at = #{claimedAt}, "
-            + "claim_token = #{claimToken} "
+            + "claim_token = #{claimToken}, gmt_modified = #{claimedAt} "
             + "WHERE id = #{id} AND ((status IN ('PENDING', 'RETRY_WAIT') AND 
next_attempt_at <= #{now}) "
             + "OR (status = 'SENDING' AND (sending_started_at IS NULL OR 
sending_started_at <= #{staleBefore})))")
     int claimForDispatch(@Param("id") Long id, @Param("now") LocalDateTime now,
             @Param("staleBefore") LocalDateTime staleBefore, 
@Param("claimedAt") LocalDateTime claimedAt,
             @Param("claimToken") String claimToken);
 
-    @Update("UPDATE rmq_alert_notification_outbox SET sending_started_at = 
#{renewedAt} "
+    // gmt_modified is set explicitly so the caller's clock wins over the 
column's
+    // ON UPDATE CURRENT_TIMESTAMP default (the database session's clock).
+    @Update("UPDATE rmq_alert_notification_outbox SET sending_started_at = 
#{renewedAt}, "
+            + "gmt_modified = #{renewedAt} "
             + "WHERE id = #{id} AND status = 'SENDING' AND claim_token = 
#{claimToken}")
     int renewClaim(@Param("id") Long id, @Param("claimToken") String 
claimToken,
             @Param("renewedAt") LocalDateTime renewedAt);
diff --git a/server/src/main/resources/application.yml 
b/server/src/main/resources/application.yml
index cf2f41b8c..a6d174bef 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -22,6 +22,9 @@ spring:
     properties:
       mail.smtp.auth: true
       mail.smtp.starttls.enable: true
+      mail.smtp.connectiontimeout: 
${STUDIO_ALERTING_SMTP_CONNECTION_TIMEOUT:5000}
+      mail.smtp.timeout: ${STUDIO_ALERTING_SMTP_READ_TIMEOUT:10000}
+      mail.smtp.writetimeout: ${STUDIO_ALERTING_SMTP_WRITE_TIMEOUT:10000}
   ai:
     mcp:
       server:
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
index 776ff92bf..b96f8fecc 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxMapperIntegrationTest.java
@@ -139,6 +139,34 @@ class NotificationOutboxMapperIntegrationTest {
         }
     }
 
+    @Test
+    void claimAndRenewStampGmtModifiedFromTheCallerClockTest() {
+        List<Long> alertIds = List.of(ALERT_ID_BASE + 201);
+        cleanup(alertIds);
+        try {
+            insert(alertIds.get(0), "dingtalk", 
NotificationOutboxStatus.PENDING, null, LocalDateTime.now());
+            Long id = mapper.selectList(new 
QueryWrapper<RmqAlertNotificationOutbox>()
+                    .in("alert_id", alertIds)).get(0).getId();
+            // Whole-second values: the datetime column drops sub-second 
precision on read-back.
+            LocalDateTime claimedAt = LocalDateTime.of(2026, 9, 30, 8, 0, 0);
+            LocalDateTime renewedAt = LocalDateTime.of(2026, 9, 30, 8, 0, 20);
+
+            // The claims carry the dispatch clock. Without an explicit 
gmt_modified the column falls
+            // back to ON UPDATE CURRENT_TIMESTAMP - the database session 
clock, not the caller's.
+            // The due window looks a minute ahead: datetime rounds the insert 
timestamp up to the
+            // whole second, so a wall-clock "now" can fall a fraction before 
the stored value.
+            LocalDateTime claimNow = LocalDateTime.now().plusMinutes(1);
+            assertThat(mapper.claimForDispatch(id, claimNow, 
claimNow.minusMinutes(5),
+                    claimedAt, "token-a")).isEqualTo(1);
+            
assertThat(mapper.selectById(id).getGmtModified()).isEqualTo(claimedAt);
+
+            assertThat(mapper.renewClaim(id, "token-a", 
renewedAt)).isEqualTo(1);
+            
assertThat(mapper.selectById(id).getGmtModified()).isEqualTo(renewedAt);
+        } finally {
+            cleanup(alertIds);
+        }
+    }
+
     @Test
     void deleteTerminalBeforeShouldRespectBatchLimitTest() {
         LocalDateTime expired = LocalDateTime.now().minusDays(31);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
index aea9578c0..2e9ca586d 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
@@ -901,4 +901,151 @@ class NotificationOutboxServiceTest {
 
         server.verify();
     }
+
+    @Test
+    void enqueuedRowsStampCreateAndModifyWithUtcTest() {
+        TimeZone previous = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+            AlertSilenceService silences = mock(AlertSilenceService.class);
+            AlertRuleVO rule = 
AlertRuleVO.builder().id(4L).domain(AlertDomain.BUSINESS)
+                    .channels(List.of("dingtalk")).build();
+            SystemAlertVO alert = 
SystemAlertVO.builder().id(9L).level(AlertLevel.warning).title("Lag")
+                    .description("lag is 
high").instanceId("local").time(LocalDateTime.now()).build();
+            when(silences.activeUntil(rule, "local", java.util.Map.of(), 
alert.getTime())).thenReturn(null);
+
+            new NotificationOutboxService(mapper, 
mock(SettingsRepository.class), silences,
+                    mock(AlertRepository.class), 
mock(OperationAuditService.class)).enqueue(alert, rule);
+
+            org.mockito.ArgumentCaptor<RmqAlertNotificationOutbox> row =
+                    
org.mockito.ArgumentCaptor.forClass(RmqAlertNotificationOutbox.class);
+            verify(mapper).insert(row.capture());
+            // Without an explicit stamp the database fills these from DEFAULT 
CURRENT_TIMESTAMP -
+            // the session clock, Asia/Shanghai in the compose deploy - while 
the retention cutoff
+            // is computed in UTC, so the two sides of the comparison live in 
different timezones.
+            assertUtcRecent(row.getValue().getGmtCreate());
+            assertUtcRecent(row.getValue().getGmtModified());
+        } finally {
+            TimeZone.setDefault(previous);
+        }
+    }
+
+    @Test
+    void deliveredTerminalWriteStampsGmtModifiedInUtcTest() {
+        TimeZone previous = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+            SettingsRepository settings = mock(SettingsRepository.class);
+            AlertRepository alerts = mock(AlertRepository.class);
+            RestTemplate client = new RestTemplate();
+            MockRestServiceServer server = 
MockRestServiceServer.bindTo(client).build();
+            RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+            row.setId(8L);
+            row.setAlertId(9L);
+            row.setChannel("dingtalk");
+            row.setStatus("PENDING");
+            row.setAttemptCount(0);
+            when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                    .thenReturn(List.of(row));
+            when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                    any(LocalDateTime.class), anyString())).thenReturn(1);
+            when(mapper.update(any(), any())).thenReturn(1);
+            
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+                    
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local")
+                    .labels(java.util.Map.of()).build()));
+            
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+                    .dingtalkWebhook("https://example.com/hook";).build());
+            server.expect(once(), requestTo("https://example.com/hook";))
+                    .andRespond(withSuccess("{\"errcode\":0}", 
MediaType.APPLICATION_JSON));
+
+            new NotificationOutboxService(mapper, settings, 
mock(AlertSilenceService.class), alerts,
+                    mock(OperationAuditService.class), client).dispatch();
+
+            server.verify();
+            assertUtcRecent(stampedAt(capturedUpdate(mapper), "gmt_modified"));
+        } finally {
+            TimeZone.setDefault(previous);
+        }
+    }
+
+    @Test
+    void exhaustedDeliveryStampsGmtModifiedOnTheFailedWriteTest() {
+        TimeZone previous = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+            // The fifth attempt ends the delivery as FAILED, the row the 
retention purge qualifies
+            // by gmt_modified - so this write is the one that decides when 
the row is deleted.
+            RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+            row.setId(8L);
+            row.setAlertId(9L);
+            row.setChannel("dingtalk");
+            row.setStatus("PENDING");
+            row.setAttemptCount(4);
+            when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                    .thenReturn(List.of(row));
+            when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                    any(LocalDateTime.class), anyString())).thenReturn(1);
+            when(mapper.update(any(), any())).thenReturn(1);
+            AlertRepository alerts = mock(AlertRepository.class);
+            
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L).build()));
+
+            new NotificationOutboxService(mapper, 
mock(SettingsRepository.class), mock(AlertSilenceService.class),
+                    alerts, mock(OperationAuditService.class)).dispatch();
+
+            assertUtcRecent(stampedAt(capturedUpdate(mapper), "gmt_modified"));
+        } finally {
+            TimeZone.setDefault(previous);
+        }
+    }
+
+    @Test
+    void manualRetryStampsGmtModifiedInUtcTest() {
+        TimeZone previous = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+            RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+            row.setId(8L);
+            row.setAlertId(9L);
+            row.setChannel("dingtalk");
+            row.setStatus(NotificationOutboxStatus.FAILED.name());
+            when(mapper.selectById(8L)).thenReturn(row);
+            when(mapper.update(org.mockito.ArgumentMatchers.isNull(), 
any(UpdateWrapper.class))).thenReturn(1);
+
+            new NotificationOutboxService(mapper, 
mock(SettingsRepository.class), mock(AlertSilenceService.class),
+                    mock(AlertRepository.class), 
mock(OperationAuditService.class)).retryFailedDelivery(8L);
+
+            assertUtcRecent(stampedAt(capturedUpdate(mapper), "gmt_modified"));
+        } finally {
+            TimeZone.setDefault(previous);
+        }
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private static UpdateWrapper<RmqAlertNotificationOutbox> 
capturedUpdate(RmqAlertNotificationOutboxMapper mapper) {
+        org.mockito.ArgumentCaptor<UpdateWrapper> captor = 
org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(mapper).update(any(), captor.capture());
+        return captor.getValue();
+    }
+
+    private static LocalDateTime 
stampedAt(UpdateWrapper<RmqAlertNotificationOutbox> wrapper, String column) {
+        String sqlSet = wrapper.getSqlSet() == null ? "" : wrapper.getSqlSet();
+        assertThat(sqlSet).as("UPDATE set clause for " + 
column).contains(column + "=");
+        java.util.regex.Matcher placeholder = java.util.regex.Pattern
+                .compile(column + 
"=#\\{ew\\.paramNameValuePairs\\.([^}]+)\\}").matcher(sqlSet);
+        if (!placeholder.find()) {
+            return null;
+        }
+        Object value = 
wrapper.getParamNameValuePairs().get(placeholder.group(1));
+        return value instanceof LocalDateTime ? (LocalDateTime) value : null;
+    }
+
+    private static void assertUtcRecent(LocalDateTime stamp) {
+        assertThat(stamp).as("explicit UTC timestamp").isNotNull();
+        assertThat(Duration.between(stamp.toInstant(ZoneOffset.UTC), 
java.time.Instant.now()).abs())
+                .isLessThan(Duration.ofSeconds(2));
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SmtpTimeoutTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SmtpTimeoutTest.java
new file mode 100644
index 000000000..ce19cb4ee
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/SmtpTimeoutTest.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
+import 
org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.mail.MailSendException;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * The alert e-mail path hands {@code sender.send(...)} to the scheduler 
thread that every
+ * {@code @Scheduled} job shares, so the JavaMail socket timeouts configured in
+ * {@code application.yml} are what keep a slow or wedged SMTP relay from 
stalling the whole
+ * backend. These tests pin the shipped defaults, the deployment overrides, 
and the behaviour of
+ * a send aimed at a peer that accepts the connection and never answers.
+ */
+class SmtpTimeoutTest {
+
+    private static final int SEND_GUARD_SECONDS = 25;
+
+    private final ApplicationContextRunner runner = new 
ApplicationContextRunner()
+            .withInitializer(new ConfigDataApplicationContextInitializer())
+            
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class));
+
+    @Test
+    void shippedMailPropertiesBoundEverySocketTimeoutTest() {
+        runner.run(context -> {
+            Properties properties = 
context.getBean(JavaMailSenderImpl.class).getJavaMailProperties();
+
+            assertThat(properties)
+                    .as("spring.mail.properties must bound every JavaMail 
socket timeout")
+                    .containsKeys("mail.smtp.connectiontimeout", 
"mail.smtp.timeout", "mail.smtp.writetimeout");
+            assertThat(timeoutMillis(properties, 
"mail.smtp.connectiontimeout")).isEqualTo(5000);
+            assertThat(timeoutMillis(properties, 
"mail.smtp.timeout")).isEqualTo(10000);
+            assertThat(timeoutMillis(properties, 
"mail.smtp.writetimeout")).isEqualTo(10000);
+        });
+    }
+
+    @Test
+    void socketTimeoutsAreDeploymentOverridableTest() {
+        runner.withPropertyValues(
+                        "STUDIO_ALERTING_SMTP_CONNECTION_TIMEOUT=1234",
+                        "STUDIO_ALERTING_SMTP_READ_TIMEOUT=2345",
+                        "STUDIO_ALERTING_SMTP_WRITE_TIMEOUT=3456")
+                .run(context -> {
+                    Properties properties = 
context.getBean(JavaMailSenderImpl.class).getJavaMailProperties();
+
+                    assertThat(timeoutMillis(properties, 
"mail.smtp.connectiontimeout")).isEqualTo(1234);
+                    assertThat(timeoutMillis(properties, 
"mail.smtp.timeout")).isEqualTo(2345);
+                    assertThat(timeoutMillis(properties, 
"mail.smtp.writetimeout")).isEqualTo(3456);
+                });
+    }
+
+    @Test
+    void sendFailsFastWhenSmtpServerNeverAnswersTest() throws Exception {
+        try (SilentSmtpServer server = new SilentSmtpServer()) {
+            // A deployment that actually sends alerts has credentials 
configured, so the client
+            // opens the transport and waits for the SMTP greeting instead of 
failing before any I/O.
+            runner.withPropertyValues("spring.mail.host=127.0.0.1", 
"spring.mail.port=" + server.port(),
+                            "spring.mail.username=probe-user", 
"spring.mail.password=probe-secret")
+                    .run(context -> {
+                        JavaMailSender sender = 
context.getBean(JavaMailSender.class);
+                        SimpleMailMessage message = new SimpleMailMessage();
+                        message.setFrom("[email protected]");
+                        message.setTo("[email protected]");
+                        message.setSubject("[RocketMQ Studio] timeout probe");
+                        message.setText("probe");
+
+                        assertSendTerminates(sender, message);
+                    });
+        }
+    }
+
+    /**
+     * Runs the send on a daemon thread so a wedged socket cannot outlive the 
test JVM, and fails
+     * with the defect statement when the call is still blocked once the guard 
expires.
+     */
+    private void assertSendTerminates(JavaMailSender sender, SimpleMailMessage 
message) {
+        ExecutorService executor = Executors.newSingleThreadExecutor(worker -> 
{
+            Thread thread = new Thread(worker, "smtp-send-under-test");
+            thread.setDaemon(true);
+            return thread;
+        });
+        try {
+            Future<?> send = executor.submit(() -> sender.send(message));
+            try {
+                send.get(SEND_GUARD_SECONDS, TimeUnit.SECONDS);
+            } catch (TimeoutException blocked) {
+                fail("sender.send() was still blocked after " + 
SEND_GUARD_SECONDS
+                        + "s against an SMTP peer that accepted the connection 
and never replied: "
+                        + "spring.mail.properties sets no 
mail.smtp.connectiontimeout / mail.smtp.timeout / "
+                        + "mail.smtp.writetimeout, so a wedged relay pins its 
caller forever (in production "
+                        + "that caller is the single @Scheduled dispatcher 
shared by all scheduled jobs)");
+            } catch (ExecutionException rejected) {
+                assertThat(rejected.getCause())
+                        .as("the send must fail with a bounded mail error 
rather than hang")
+                        .isInstanceOf(MailSendException.class)
+                        .hasRootCauseInstanceOf(SocketTimeoutException.class);
+            } catch (InterruptedException interrupted) {
+                Thread.currentThread().interrupt();
+                fail("interrupted while waiting for the SMTP send to return");
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    private static int timeoutMillis(Properties properties, String key) {
+        Object value = properties.get(key);
+        assertThat(value).as("JavaMail property %s must be set", 
key).isNotNull();
+        return Integer.parseInt(String.valueOf(value));
+    }
+
+    /**
+     * Accepts SMTP connections and then stays silent: no 220 banner, no 
reply. JavaMail connects
+     * successfully and waits for the greeting, which is exactly the read that 
an unbounded
+     * {@code mail.smtp.timeout} never finishes.
+     */
+    private static final class SilentSmtpServer implements AutoCloseable {
+
+        private final ServerSocket server;
+        private final List<Socket> accepted = new ArrayList<>();
+        private final Thread acceptor;
+
+        private SilentSmtpServer() throws IOException {
+            this.server = new ServerSocket(0, 1, 
InetAddress.getLoopbackAddress());
+            this.acceptor = new Thread(this::acceptQuietly, 
"silent-smtp-acceptor");
+            this.acceptor.setDaemon(true);
+            this.acceptor.start();
+        }
+
+        private void acceptQuietly() {
+            while (!server.isClosed()) {
+                try {
+                    accepted.add(server.accept());
+                } catch (IOException closed) {
+                    return;
+                }
+            }
+        }
+
+        private int port() {
+            return server.getLocalPort();
+        }
+
+        @Override
+        public void close() {
+            try {
+                server.close();
+            } catch (IOException ignored) {
+                // best effort: only used to unblock a hung client at the end 
of the test
+            }
+            for (Socket socket : accepted) {
+                try {
+                    socket.close();
+                } catch (IOException ignored) {
+                    // best effort: closing the accepted sockets wakes the 
client read
+                }
+            }
+        }
+    }
+}

Reply via email to