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 f1d4a17f2 feat(dlq): message details, selected resend and Excel export 
(#2513)
f1d4a17f2 is described below

commit f1d4a17f2df42e972d1667855eb0e970cfd06801
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 25 16:57:08 2026 +0800

    feat(dlq): message details, selected resend and Excel export (#2513)
    
    - DLQProvider: add listMessages (paged details), resendMessages by msgId
      and exportExcel; Apache impl scans %DLQ%group via pull consumer and
      re-delivers selected dead letters to their origin/target topic
    - DLQController: GET /api/dlq/{group}/messages (paged detail),
      POST /api/dlq/resend-selected (by msgId), GET /api/dlq/export-excel
      (single or batch); Excel via EasyExcel 2.2.10 (xlsx)
    - dlq page: replace the group-stats dialog with a message detail drawer
      (paged table, row selection, single/batch resend, Excel export); row
      export now downloads .xlsx instead of .json
---
 server/pom.xml                                     |   6 +
 .../common/exception/GlobalExceptionHandler.java   |  16 +
 .../studio/instance/dlq/DLQController.java         |  84 ++++-
 ...LQProvider.java => DLQExcelExportResultVO.java} |  29 +-
 .../studio/instance/dlq/DLQMessageExcelRow.java    |  63 ++++
 .../rocketmq/studio/instance/dlq/DLQProvider.java  |  21 ++
 .../studio/instance/dlq/DLQProviderStub.java       |  22 ++
 ...vider.java => DLQResendSelectedRequestDTO.java} |  29 +-
 .../rocketmq/studio/instance/dlq/DLQService.java   |  35 ++
 .../provider/apache/RocketMQDLQProvider.java       | 131 ++++++++
 .../studio/instance/dlq/DLQControllerTest.java     | 182 ++++++++++
 web/src/api/dlq.test.ts                            |  56 +++-
 web/src/api/message.ts                             |  69 ++++
 web/src/pages/instance/__tests__/DLQPage.test.tsx  |  47 ++-
 web/src/pages/instance/dlq.tsx                     | 371 +++++++++++++++++----
 web/src/services/messageService.ts                 |  45 +++
 16 files changed, 1091 insertions(+), 115 deletions(-)

diff --git a/server/pom.xml b/server/pom.xml
index ca6c5c04d..622dcea09 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -124,6 +124,12 @@
             <artifactId>kotlin-stdlib</artifactId>
             <version>1.9.25</version>
         </dependency>
+        <!-- Excel export for DLQ message details (single / batch). -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>easyexcel</artifactId>
+            <version>3.3.4</version>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
index e0b3c33f0..2fe73fffe 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.common.exception;
 
+import jakarta.validation.ConstraintViolationException;
 import org.apache.rocketmq.studio.cluster.metrics.PrometheusException;
 import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
@@ -75,6 +76,21 @@ public class GlobalExceptionHandler {
         return Result.error(HttpStatus.BAD_REQUEST.value(), message);
     }
 
+    /**
+     * Constraint violations on {@code @Validated} request parameters (e.g. 
{@code @Size}
+     * on a {@code @RequestParam}) surface as {@link 
ConstraintViolationException} rather
+     * than {@link MethodArgumentNotValidException}; report them as 400 as 
well.
+     */
+    @ExceptionHandler(ConstraintViolationException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<?> 
handleConstraintViolationException(ConstraintViolationException ex) {
+        String message = ex.getConstraintViolations().stream()
+                .findFirst()
+                .map(violation -> violation.getMessage() == null ? "Invalid 
request" : violation.getMessage())
+                .orElse("Invalid request");
+        return Result.error(HttpStatus.BAD_REQUEST.value(), message);
+    }
+
     @ExceptionHandler(HttpMessageNotReadableException.class)
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     public Result<?> 
handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
index d5b69b379..f2444bbbd 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
@@ -22,12 +22,17 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Size;
 import lombok.RequiredArgsConstructor;
 import org.springframework.http.ContentDisposition;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -35,15 +40,20 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.nio.charset.StandardCharsets;
+import java.util.List;
 
 @RestController
 @RequestMapping("/api/dlq")
 @RequiredArgsConstructor
+@Validated
 public class DLQController {
 
     private static final String HEADER_EXPORT_TRUNCATED = 
"X-DLQ-Export-Truncated";
     private static final String HEADER_EXPORT_FAILED_QUEUES = 
"X-DLQ-Export-FailedQueues";
     private static final String HEADER_EXPORT_LIMIT = "X-DLQ-Export-Limit";
+    private static final String EXCEL_MEDIA_TYPE =
+            
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+    private static final int MAX_SELECTED_MESSAGES = 100;
 
     private final DLQService dlqService;
     private final ObjectMapper objectMapper;
@@ -63,6 +73,46 @@ public class DLQController {
                 request.getStartTime(), request.getEndTime(), 
request.getTargetTopic()));
     }
 
+    @PostMapping("/resend-selected")
+    public Result<DLQResendResultVO> resendSelectedMessages(
+            @Valid @RequestBody(required = false) DLQResendSelectedRequestDTO 
request) {
+        requireSelectedRequest(request);
+        return 
Result.ok(dlqService.resendSelectedMessages(request.getInstanceId(), 
request.getGroupName(),
+                request.getMsgIds(), request.getTargetTopic()));
+    }
+
+    @GetMapping("/{groupName}/messages")
+    public Result<PageResult<DLQMessageVO>> listDLQMessages(@PathVariable 
String groupName,
+            @RequestParam String instanceId,
+            @RequestParam(required = false) Long startTime,
+            @RequestParam(required = false) Long endTime,
+            @Min(value = 1, message = "page must be at least 1")
+            @RequestParam(defaultValue = "1") int page,
+            @Min(value = 1, message = "pageSize must be at least 1")
+            @Max(value = 100, message = "pageSize must not exceed 100")
+            @RequestParam(defaultValue = "20") int pageSize) {
+        return Result.ok(dlqService.listMessages(instanceId, groupName, 
startTime, endTime, page, pageSize));
+    }
+
+    @GetMapping("/export-excel")
+    public ResponseEntity<byte[]> exportDLQExcel(@RequestParam String 
instanceId,
+                                                 @RequestParam String 
groupName,
+                                                 @RequestParam(required = 
false) Long startTime,
+                                                 @RequestParam(required = 
false) Long endTime,
+                                                 @Size(max = 
MAX_SELECTED_MESSAGES,
+                                                         message = "At most 
100 msgIds are allowed per export")
+                                                 @RequestParam(required = 
false) List<String> msgIds) {
+        DLQExcelExportResultVO result = dlqService.exportExcel(instanceId, 
groupName, startTime, endTime, msgIds);
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION,
+                        attachmentDisposition("dlq-" + 
sanitizeForFilename(groupName) + ".xlsx").toString())
+                .header(HEADER_EXPORT_TRUNCATED, 
String.valueOf(result.isTruncated()))
+                .header(HEADER_EXPORT_FAILED_QUEUES, 
String.valueOf(result.getFailedQueueCount()))
+                .header(HEADER_EXPORT_LIMIT, String.valueOf(result.getLimit()))
+                .contentType(MediaType.parseMediaType(EXCEL_MEDIA_TYPE))
+                .body(result.getData());
+    }
+
     @GetMapping("/export")
     public ResponseEntity<byte[]> exportDLQMessages(@RequestParam String 
instanceId,
                                                     @RequestParam String 
groupName,
@@ -77,18 +127,9 @@ public class DLQController {
         } catch (JsonProcessingException exception) {
             throw new BusinessException(500, "Failed to serialize DLQ export");
         }
-        String fileName = "dlq-" + sanitizeForFilename(groupName) + ".json";
-        ContentDisposition.Builder builder = ContentDisposition.attachment();
-        if (fileName.chars().allMatch(ch -> ch < 128)) {
-            builder.filename(fileName);
-        } else {
-            // Non-ASCII names need the RFC 5987 filename* parameter so 
browsers keep the
-            // original characters instead of the lossy ASCII fallback.
-            builder.filename(fileName, StandardCharsets.UTF_8);
-        }
-        ContentDisposition disposition = builder.build();
         return ResponseEntity.ok()
-                .header(HttpHeaders.CONTENT_DISPOSITION, 
disposition.toString())
+                .header(HttpHeaders.CONTENT_DISPOSITION,
+                        attachmentDisposition("dlq-" + 
sanitizeForFilename(groupName) + ".json").toString())
                 .header(HEADER_EXPORT_TRUNCATED, 
String.valueOf(result.isTruncated()))
                 .header(HEADER_EXPORT_FAILED_QUEUES, 
String.valueOf(result.getFailedQueueCount()))
                 .header(HEADER_EXPORT_LIMIT, String.valueOf(result.getLimit()))
@@ -96,6 +137,21 @@ public class DLQController {
                 .body(body);
     }
 
+    /**
+     * Builds an {@code attachment} Content-Disposition; non-ASCII names need 
the RFC 5987
+     * {@code filename*} parameter so browsers keep the original characters 
instead of the
+     * lossy ASCII fallback.
+     */
+    private static ContentDisposition attachmentDisposition(String fileName) {
+        ContentDisposition.Builder builder = ContentDisposition.attachment();
+        if (fileName.chars().allMatch(ch -> ch < 128)) {
+            builder.filename(fileName);
+        } else {
+            builder.filename(fileName, StandardCharsets.UTF_8);
+        }
+        return builder.build();
+    }
+
     /**
      * Strips the characters that would break a quoted RFC 6266 header value 
or confuse
      * file managers; the non-ASCII part is preserved and emitted as an RFC 
5987
@@ -115,4 +171,10 @@ public class DLQController {
             throw new BusinessException(400, "DLQ resend request is required");
         }
     }
+
+    private void requireSelectedRequest(DLQResendSelectedRequestDTO request) {
+        if (request == null) {
+            throw new BusinessException(400, "DLQ resend request is required");
+        }
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQExcelExportResultVO.java
similarity index 59%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQExcelExportResultVO.java
index 2669f66a2..48f4132de 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQExcelExportResultVO.java
@@ -16,17 +16,24 @@
  */
 package org.apache.rocketmq.studio.instance.dlq;
 
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
 
-import java.util.List;
-import org.apache.rocketmq.studio.common.domain.PageResult;
-
-public interface DLQProvider {
-    List<DLQGroupVO> listDLQGroups(String instanceId);
-
-    PageResult<DLQGroupVO> listDLQGroups(String instanceId, String search, int 
page, int pageSize);
+/**
+ * An Excel DLQ export: the serialized .xlsx bytes plus the same 
scan-completeness
+ * metadata as the JSON export, so callers can tell whether the sheet covers a 
full
+ * snapshot or a bounded/partial one.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DLQExcelExportResultVO {
 
-    DLQResendResultVO resendMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
-                                     String targetTopic);
-    DLQExportResultVO exportMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
-                                     Integer maxCount);
+    private byte[] data;
+    private boolean truncated;
+    private int failedQueueCount;
+    private int limit;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
new file mode 100644
index 000000000..0b8e7de26
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageExcelRow.java
@@ -0,0 +1,63 @@
+/*
+ * 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.instance.dlq;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Excel row model for dead-letter message export (single message or a 
selected batch).
+ */
+@Data
+public class DLQMessageExcelRow {
+
+    private static final DateTimeFormatter STORE_TIME_FORMAT =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @ExcelProperty("Message ID")
+    private String msgId;
+    @ExcelProperty("Topic")
+    private String topic;
+    @ExcelProperty("Queue ID")
+    private int queueId;
+    @ExcelProperty("Offset")
+    private long offset;
+    @ExcelProperty("Store Time")
+    private String storeTime;
+    @ExcelProperty("Keys")
+    private String keys;
+    @ExcelProperty("Body")
+    private String body;
+
+    public static DLQMessageExcelRow from(DLQMessageVO vo) {
+        DLQMessageExcelRow row = new DLQMessageExcelRow();
+        row.setMsgId(vo.getMsgId());
+        row.setTopic(vo.getTopic());
+        row.setQueueId(vo.getQueueId());
+        row.setOffset(vo.getOffset());
+        row.setStoreTime(LocalDateTime.ofInstant(
+                Instant.ofEpochMilli(vo.getStoreTime()), 
ZoneId.systemDefault()).format(STORE_TIME_FORMAT));
+        row.setKeys(vo.getKeys());
+        row.setBody(vo.getBody());
+        return row;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
index 2669f66a2..3d36f7c60 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
@@ -27,6 +27,27 @@ public interface DLQProvider {
 
     DLQResendResultVO resendMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
                                      String targetTopic);
+
     DLQExportResultVO exportMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
                                      Integer maxCount);
+
+    /**
+     * Paged dead-letter message detail query for a group within a time window.
+     */
+    PageResult<DLQMessageVO> listMessages(String instanceId, String groupName, 
Long startTime, Long endTime,
+                                          int page, int pageSize);
+
+    /**
+     * Re-deliver only the selected dead-letter messages (by msgId) back to 
their origin or a target topic.
+     */
+    DLQResendResultVO resendMessages(String instanceId, String groupName, 
List<String> msgIds,
+                                     String targetTopic);
+
+    /**
+     * Excel (.xlsx) export of dead-letter messages; when {@code msgIds} is 
non-empty only those
+     * messages are included, otherwise the whole time window is exported. The 
result carries the
+     * serialized sheet plus scan-completeness metadata, mirroring the JSON 
export.
+     */
+    DLQExcelExportResultVO exportExcel(String instanceId, String groupName, 
Long startTime, Long endTime,
+                                       List<String> msgIds);
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
index a6e209e00..049261485 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
@@ -58,6 +58,28 @@ public class DLQProviderStub implements DLQProvider {
         throw unsupported();
     }
 
+    @Override
+    public PageResult<DLQMessageVO> listMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                                 int page, int pageSize) {
+        log.warn("DLQProviderStub.listMessages called but no real DLQ provider 
is configured. group={}", groupName);
+        throw unsupported();
+    }
+
+    @Override
+    public DLQResendResultVO resendMessages(String instanceId, String 
groupName, List<String> msgIds,
+                                             String targetTopic) {
+        log.warn("DLQProviderStub.resendMessages(selected) called but no real 
DLQ provider is configured. group={}",
+                groupName);
+        throw unsupported();
+    }
+
+    @Override
+    public DLQExcelExportResultVO exportExcel(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                              List<String> msgIds) {
+        log.warn("DLQProviderStub.exportExcel called but no real DLQ provider 
is configured. group={}", groupName);
+        throw unsupported();
+    }
+
     private BusinessException unsupported() {
         return new BusinessException(501, "DLQ provider is not configured");
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQResendSelectedRequestDTO.java
similarity index 55%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQResendSelectedRequestDTO.java
index 2669f66a2..73c20c266 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQResendSelectedRequestDTO.java
@@ -16,17 +16,30 @@
  */
 package org.apache.rocketmq.studio.instance.dlq;
 
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
 
 import java.util.List;
-import org.apache.rocketmq.studio.common.domain.PageResult;
 
-public interface DLQProvider {
-    List<DLQGroupVO> listDLQGroups(String instanceId);
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DLQResendSelectedRequestDTO {
+    @NotBlank(message = "instanceId is required")
+    private String instanceId;
 
-    PageResult<DLQGroupVO> listDLQGroups(String instanceId, String search, int 
page, int pageSize);
+    @NotBlank(message = "groupName is required")
+    private String groupName;
 
-    DLQResendResultVO resendMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
-                                     String targetTopic);
-    DLQExportResultVO exportMessages(String instanceId, String groupName, Long 
startTime, Long endTime,
-                                     Integer maxCount);
+    @NotEmpty(message = "At least one msgId is required")
+    @Size(max = 100, message = "At most 100 msgIds are allowed per resend")
+    private List<String> msgIds;
+
+    private String targetTopic;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
index 860300ebd..87434d9a1 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
@@ -33,6 +33,7 @@ import java.util.List;
 public class DLQService {
 
     private static final int MAX_PAGE_SIZE = 100;
+    private static final int MAX_SELECTED_MESSAGES = 100;
 
     private final DLQProvider dlqProvider;
     private final InstanceProviderRegistry providerRegistry;
@@ -68,6 +69,40 @@ public class DLQService {
         return dlqProvider.exportMessages(instanceId, groupName, startTime, 
endTime, maxCount);
     }
 
+    public PageResult<DLQMessageVO> listMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                                 int page, int pageSize) {
+        requireApacheInstance(instanceId);
+        validateResendRequest(groupName, startTime, endTime);
+        log.info("Listing DLQ messages: group={}, page={}, pageSize={}", 
groupName, page, pageSize);
+        return dlqProvider.listMessages(instanceId, groupName, startTime, 
endTime, page, pageSize);
+    }
+
+    public DLQResendResultVO resendSelectedMessages(String instanceId, String 
groupName, List<String> msgIds,
+                                                    String targetTopic) {
+        requireApacheInstance(instanceId);
+        if (msgIds == null || msgIds.isEmpty()) {
+            throw new BusinessException(400, "At least one msgId is required");
+        }
+        if (msgIds.size() > MAX_SELECTED_MESSAGES) {
+            throw new BusinessException(400, "At most 100 msgIds are allowed 
per resend");
+        }
+        log.info("Resending selected DLQ messages: group={}, count={}, 
targetTopic={}",
+                groupName, msgIds.size(), targetTopic);
+        return dlqProvider.resendMessages(instanceId, groupName, msgIds, 
targetTopic);
+    }
+
+    public DLQExcelExportResultVO exportExcel(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                              List<String> msgIds) {
+        requireApacheInstance(instanceId);
+        validateResendRequest(groupName, startTime, endTime);
+        if (msgIds != null && msgIds.size() > MAX_SELECTED_MESSAGES) {
+            throw new BusinessException(400, "At most 100 msgIds are allowed 
per export");
+        }
+        log.info("Exporting DLQ messages as Excel: group={}, selected={}", 
groupName,
+                msgIds == null ? 0 : msgIds.size());
+        return dlqProvider.exportExcel(instanceId, groupName, startTime, 
endTime, msgIds);
+    }
+
     private void requireApacheInstance(String instanceId) {
         providerRegistry.byInstanceId(instanceId).ifPresent(provider -> {
             if (provider.vendor() != InstanceVendor.APACHE) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index 998939600..5f2ec556c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -34,8 +34,10 @@ import 
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.Pagination;
+import org.apache.rocketmq.studio.instance.dlq.DLQExcelExportResultVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
+import org.apache.rocketmq.studio.instance.dlq.DLQMessageExcelRow;
 import org.apache.rocketmq.studio.instance.dlq.DLQMessageVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQProvider;
 import org.apache.rocketmq.studio.instance.dlq.DLQResendResultVO;
@@ -47,6 +49,7 @@ import org.springframework.util.StringUtils;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
+import java.io.ByteArrayOutputStream;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.LocalDateTime;
@@ -72,6 +75,7 @@ public class RocketMQDLQProvider implements DLQProvider {
 
     private static final long ONE_HOUR_MILLIS = 3600_000L;
     private static final int RESEND_HARD_CAP = 5000;
+    private static final int MAX_PAGE_SIZE = 100;
     private static final int MAX_CONSECUTIVE_OFFSET_ILLEGAL = 3;
     private static final String ORIGIN_MESSAGE_ID_PROPERTY = 
"studio_dlq_origin_message_id";
     private static final String ORIGIN_TOPIC_PROPERTY = 
"studio_dlq_origin_topic";
@@ -227,6 +231,133 @@ public class RocketMQDLQProvider implements DLQProvider {
                 .build();
     }
 
+    @Override
+    public DLQResendResultVO resendMessages(String instanceId, String 
groupName, List<String> msgIds,
+                                             String targetTopic) {
+        if (!StringUtils.hasText(groupName)) {
+            throw new BusinessException(400, "groupName is required for DLQ 
resend");
+        }
+        if (msgIds == null || msgIds.isEmpty()) {
+            throw new BusinessException(400, "At least one msgId is required 
for selected DLQ resend");
+        }
+        groupName = groupName.trim();
+        Set<String> selected = new java.util.HashSet<>();
+        for (String msgId : msgIds) {
+            if (StringUtils.hasText(msgId)) {
+                selected.add(msgId.trim());
+            }
+        }
+        if (selected.isEmpty()) {
+            throw new BusinessException(400, "At least one valid msgId is 
required for selected DLQ resend");
+        }
+
+        String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+
+        // Scan a wide window so the selected messages are found regardless of 
their store time.
+        long end = System.currentTimeMillis();
+        long begin = end - 7 * 24 * ONE_HOUR_MILLIS;
+        DeadLetterScanResult scanResult =
+                collectDeadLetters(instanceId, dlqTopic, begin, end, 
RESEND_HARD_CAP);
+        List<MessageExt> deadLetters = scanResult.messages().stream()
+                .filter(message -> selected.contains(message.getMsgId()))
+                .toList();
+        int[] counts = {0, 0};
+        if (!deadLetters.isEmpty()) {
+            try {
+                runtimeAdminClientResolver.executeProducer(instanceId, 
producer -> {
+                    for (MessageExt deadLetter : deadLetters) {
+                        if (resendOne(producer, deadLetter, targetTopic)) {
+                            counts[0]++;
+                        } else {
+                            counts[1]++;
+                        }
+                    }
+                    return null;
+                });
+            } catch (Exception e) {
+                log.warn("Failed to resend selected dead letters for group {}: 
{}", groupName, e.getMessage());
+                counts[1] += deadLetters.size() - counts[0];
+            }
+        }
+        int resent = counts[0];
+        int failed = counts[1];
+        boolean foundAll = deadLetters.size() == selected.size();
+
+        String outcome = classifyOutcome(deadLetters.size(), resent, failed, 
!foundAll);
+        String detail = String.format("instanceId=%s, group=%s, selected=%d, 
matched=%d, resent=%d, failed=%d",
+                instanceId, groupName, selected.size(), deadLetters.size(), 
resent, failed);
+        recordAudit(groupName, detail, outcome);
+        log.info("Selected DLQ resend completed: {}", detail);
+        return DLQResendResultVO.builder()
+                .matched(deadLetters.size())
+                .resent(resent)
+                .failed(failed)
+                .outcome(outcome)
+                .build();
+    }
+
+    @Override
+    public PageResult<DLQMessageVO> listMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                                 int page, int pageSize) {
+        if (!StringUtils.hasText(groupName)) {
+            throw new BusinessException(400, "groupName is required for DLQ 
message details");
+        }
+        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+            throw new BusinessException(400, "Invalid page or pageSize");
+        }
+        groupName = groupName.trim();
+        String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+        long end = endTime != null ? endTime : System.currentTimeMillis();
+        long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
+        if (begin >= end) {
+            throw new BusinessException(400, "DLQ detail start time must be 
before end time");
+        }
+        List<DLQMessageVO> all = collectDeadLetters(instanceId, dlqTopic, 
begin, end, RESEND_HARD_CAP)
+                .messages().stream()
+                .map(this::toExportVO)
+                .toList();
+        long offset = Pagination.pageOffset(page, pageSize);
+        int from = (int) Math.min(offset, all.size());
+        int to = (int) Math.min(offset + pageSize, all.size());
+        return PageResult.of(all.subList(from, to), all.size(), page, 
pageSize);
+    }
+
+    @Override
+    public DLQExcelExportResultVO exportExcel(String instanceId, String 
groupName, Long startTime, Long endTime,
+                                              List<String> msgIds) {
+        if (!StringUtils.hasText(groupName)) {
+            throw new BusinessException(400, "groupName is required for DLQ 
export");
+        }
+        groupName = groupName.trim();
+        String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
+        long end = endTime != null ? endTime : System.currentTimeMillis();
+        long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
+        DeadLetterScanResult scanResult = collectDeadLetters(instanceId, 
dlqTopic, begin, end, RESEND_HARD_CAP);
+        Set<String> selected = msgIds == null ? Collections.emptySet()
+                : new java.util.HashSet<>(msgIds);
+        List<DLQMessageVO> messages = scanResult.messages().stream()
+                .filter(message -> selected.isEmpty() || 
selected.contains(message.getMsgId()))
+                .map(this::toExportVO)
+                .toList();
+        byte[] data;
+        try {
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            com.alibaba.excel.EasyExcel.write(output, DLQMessageExcelRow.class)
+                    .sheet("DLQ")
+                    
.doWrite(messages.stream().map(DLQMessageExcelRow::from).toList());
+            data = output.toByteArray();
+        } catch (Exception e) {
+            log.warn("Failed to build Excel export for group {}: {}", 
groupName, e.getMessage());
+            throw new BusinessException(502, "Failed to export DLQ messages as 
Excel: " + e.getMessage());
+        }
+        return DLQExcelExportResultVO.builder()
+                .data(data)
+                .truncated(scanResult.truncated())
+                .failedQueueCount(scanResult.failedQueueCount())
+                .limit(RESEND_HARD_CAP)
+                .build();
+    }
+
     @Override
     public DLQExportResultVO exportMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
                                             Integer maxCount) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
index a005fcfd8..748913621 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
@@ -327,4 +327,186 @@ class DLQControllerTest {
                             "unexpected content disposition: " + disposition);
                 });
     }
+
+    @Test
+    void listDLQMessagesShouldReturnPageTest() throws Exception {
+        when(dlqService.listMessages(eq("instance-1"), eq("test-group"), 
isNull(), isNull(), eq(1), eq(20)))
+                .thenReturn(PageResult.of(List.of(
+                        DLQMessageVO.builder()
+                                .msgId("msg-1")
+                                .topic("%DLQ%test-group")
+                                .queueId(0)
+                                .offset(5L)
+                                .storeTime(150L)
+                                .keys("key-a")
+                                .body("hello dlq")
+                                .build()), 1, 1, 20));
+
+        mockMvc.perform(get("/api/dlq/test-group/messages")
+                        .param("instanceId", "instance-1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.items[0].msgId").value("msg-1"))
+                .andExpect(jsonPath("$.data.total").value(1));
+
+        verify(dlqService).listMessages(eq("instance-1"), eq("test-group"), 
isNull(), isNull(), eq(1), eq(20));
+    }
+
+    @Test
+    void listDLQMessagesShouldRejectOversizedPageSizeTest() throws Exception {
+        mockMvc.perform(get("/api/dlq/test-group/messages")
+                        .param("instanceId", "instance-1")
+                        .param("pageSize", "500"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("pageSize must not 
exceed 100"));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void listDLQMessagesShouldRejectZeroPageTest() throws Exception {
+        mockMvc.perform(get("/api/dlq/test-group/messages")
+                        .param("instanceId", "instance-1")
+                        .param("page", "0"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("page must be at least 
1"));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void resendSelectedMessagesShouldReturnSuccessTest() throws Exception {
+        Map<String, Object> body = Map.of(
+                "instanceId", "instance-1",
+                "groupName", "test-group",
+                "msgIds", List.of("msg-1", "msg-2"),
+                "targetTopic", "target-topic"
+        );
+
+        mockMvc.perform(post("/api/dlq/resend-selected")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(body)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"));
+
+        verify(dlqService).resendSelectedMessages(
+                eq("instance-1"), eq("test-group"), eq(List.of("msg-1", 
"msg-2")), eq("target-topic"));
+    }
+
+    @Test
+    void resendSelectedMessagesShouldRejectNullRequestBodyTest() throws 
Exception {
+        mockMvc.perform(post("/api/dlq/resend-selected")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("null"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("DLQ resend request is 
required"));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void resendSelectedMessagesShouldRejectEmptyMsgIdsTest() throws Exception {
+        Map<String, Object> body = Map.of(
+                "instanceId", "instance-1",
+                "groupName", "test-group",
+                "msgIds", List.of()
+        );
+
+        mockMvc.perform(post("/api/dlq/resend-selected")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(body)))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("At least one msgId is 
required"));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void resendSelectedMessagesShouldRejectMoreThanHundredMsgIdsTest() throws 
Exception {
+        List<String> msgIds = new java.util.ArrayList<>();
+        for (int i = 0; i < 101; i++) {
+            msgIds.add("msg-" + i);
+        }
+        Map<String, Object> body = Map.of(
+                "instanceId", "instance-1",
+                "groupName", "test-group",
+                "msgIds", msgIds
+        );
+
+        mockMvc.perform(post("/api/dlq/resend-selected")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(body)))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("At most 100 msgIds are 
allowed per resend"));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void exportDLQExcelShouldReturnAttachmentWithCompletenessHeadersTest() 
throws Exception {
+        when(dlqService.exportExcel(eq("instance-1"), eq("test-group"), 
isNull(), isNull(), isNull()))
+                .thenReturn(DLQExcelExportResultVO.builder()
+                        .data(new byte[]{1, 2, 3})
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(5000)
+                        .build());
+
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group"))
+                .andExpect(status().isOk())
+                .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=\"dlq-test-group.xlsx\""))
+                .andExpect(header().string("X-DLQ-Export-Truncated", "false"))
+                .andExpect(header().string("X-DLQ-Export-FailedQueues", "0"))
+                .andExpect(header().string("X-DLQ-Export-Limit", "5000"))
+                .andExpect(content().contentTypeCompatibleWith(
+                        
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+                .andExpect(content().bytes(new byte[]{1, 2, 3}));
+
+        verify(dlqService).exportExcel(eq("instance-1"), eq("test-group"), 
isNull(), isNull(), isNull());
+    }
+
+    @Test
+    void exportDLQExcelShouldSanitizeHeaderUnsafeGroupNameCharactersTest() 
throws Exception {
+        when(dlqService.exportExcel(eq("instance-1"), eq("we\"ird\\group"), 
isNull(), isNull(), isNull()))
+                .thenReturn(DLQExcelExportResultVO.builder()
+                        .data(new byte[]{9})
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(5000)
+                        .build());
+
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "we\"ird\\group"))
+                .andExpect(status().isOk())
+                .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=\"dlq-we_ird_group.xlsx\""));
+    }
+
+    @Test
+    void exportDLQExcelShouldRejectMoreThanHundredMsgIdsTest() throws 
Exception {
+        
org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder 
request =
+                get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group");
+        for (int i = 0; i < 101; i++) {
+            request = request.param("msgIds", "msg-" + i);
+        }
+
+        mockMvc.perform(request)
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("At most 100 msgIds are 
allowed per export"));
+
+        verifyNoInteractions(dlqService);
+    }
 }
diff --git a/web/src/api/dlq.test.ts b/web/src/api/dlq.test.ts
index 0a2c8c464..efe892d0f 100644
--- a/web/src/api/dlq.test.ts
+++ b/web/src/api/dlq.test.ts
@@ -16,9 +16,10 @@
  */
 
 import MockAdapter from 'axios-mock-adapter';
+import type { InternalAxiosRequestConfig } from 'axios';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { exportDLQMessages, listDLQGroups, resendDLQ } from './message';
+import { exportDLQExcel, exportDLQMessages, listDLQGroups, resendDLQ } from 
'./message';
 import type { DLQGroup } from './message';
 
 const mock = new MockAdapter(client);
@@ -122,4 +123,57 @@ describe('DLQ API', () => {
 
     expect(meta).toEqual({ truncated: false, failedQueueCount: 0, limit: 0 });
   });
+
+  it('serializes selected msgIds as repeated params so Spring binds them', 
async () => {
+    // axios's default serializer emits `msgIds[]=a&msgIds[]=b`, which Spring's
+    // @RequestParam List<String> does not bind — the backend would silently 
fall
+    // back to exporting the whole time window. Capture the request config that
+    // exportDLQExcel actually sends and lock its repeated-param serialization.
+    let capturedConfig: InternalAxiosRequestConfig | null = null;
+    const originalAdapter = client.defaults.adapter;
+    client.defaults.adapter = (config) => {
+      capturedConfig = config;
+      return Promise.resolve({
+        data: new Blob(['xlsx']),
+        status: 200,
+        statusText: 'OK',
+        headers: {},
+        config,
+      } as never);
+    };
+    try {
+      await exportDLQExcel({
+        instanceId: 'instance-1',
+        groupName: group.groupName,
+        msgIds: ['msg-1', 'msg-2'],
+      });
+    } finally {
+      client.defaults.adapter = originalAdapter;
+    }
+    expect(capturedConfig).not.toBeNull();
+    const serialized = client.getUri(capturedConfig as never);
+    expect(serialized).toContain('msgIds=msg-1&msgIds=msg-2');
+    expect(serialized).not.toContain('msgIds[]');
+  });
+
+  it('returns the Excel export blob with completeness metadata from the 
response headers', async () => {
+    const payload = new Blob(['xlsx'], {
+      type: 
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    });
+    mock.onGet('/dlq/export-excel').reply(200, payload, {
+      'content-type': 
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      'content-disposition': 'attachment; filename="dlq-order-consumer.xlsx"',
+      'x-dlq-export-truncated': 'true',
+      'x-dlq-export-failedqueues': '2',
+      'x-dlq-export-limit': '5000',
+    });
+
+    const { blob, meta } = await exportDLQExcel({
+      instanceId: 'instance-1',
+      groupName: group.groupName,
+    });
+
+    await expect(blob.text()).resolves.toBe('xlsx');
+    expect(meta).toEqual({ truncated: true, failedQueueCount: 2, limit: 5000 
});
+  });
 });
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index e5b705674..81df854a3 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -92,6 +92,24 @@ export interface DLQResendResult {
   failedQueueCount?: number;
 }
 
+export interface DLQMessage {
+  msgId: string;
+  topic: string;
+  queueId: number;
+  offset: number;
+  storeTime: number;
+  keys: string | null;
+  body: string | null;
+  bodyBase64: string | null;
+}
+
+export interface DLQMessagePage {
+  items: DLQMessage[];
+  total: number;
+  page: number;
+  size: number;
+}
+
 // ─── Messages ───────────────────────────────────────────────────
 export async function queryMessages(params: MessageQuery) {
   const res = await client.get<{ data: MessageRecord[] }>('/messages', { 
params });
@@ -185,3 +203,54 @@ export async function pullMessageAtOffset(params: {
   });
   return res.data.data;
 }
+
+export async function listDLQMessages(params: {
+  instanceId: string;
+  groupName: string;
+  startTime?: number;
+  endTime?: number;
+  page?: number;
+  pageSize?: number;
+}): Promise<DLQMessagePage> {
+  const res = await client.get<{ data: DLQMessagePage }>(
+    `/dlq/${encodeURIComponent(params.groupName)}/messages`,
+    { params },
+  );
+  return res.data.data;
+}
+
+export async function resendDLQSelected(data: {
+  instanceId: string;
+  groupName: string;
+  msgIds: string[];
+  targetTopic?: string;
+}): Promise<DLQResendResult> {
+  const res = await client.post<{ data: DLQResendResult 
}>('/dlq/resend-selected', data);
+  return res.data.data;
+}
+
+export async function exportDLQExcel(params: {
+  instanceId: string;
+  groupName: string;
+  startTime?: number;
+  endTime?: number;
+  msgIds?: string[];
+}): Promise<{ blob: Blob; meta: DLQExportMeta }> {
+  const res = await client.get<Blob>('/dlq/export-excel', {
+    params,
+    responseType: 'blob',
+    // Repeat the parameter name per item (`msgIds=a&msgIds=b`) instead of 
axios's default
+    // bracketed form (`msgIds[]=a`), which Spring's @RequestParam 
List<String> would not
+    // bind — the request would silently fall back to exporting the whole time 
window.
+    paramsSerializer: { indexes: null },
+  });
+  const header = (name: string): string => String(res.headers[name] ?? '');
+  return {
+    blob: res.data,
+    meta: {
+      truncated: header('x-dlq-export-truncated') === 'true',
+      failedQueueCount: Number.parseInt(header('x-dlq-export-failedqueues'), 
10) || 0,
+      limit: Number.parseInt(header('x-dlq-export-limit'), 10) || 0,
+    },
+  };
+}
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 02a578a1e..55860b563 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -21,7 +21,7 @@ import userEvent from '@testing-library/user-event';
 import type React from 'react';
 import { MemoryRouter } from 'react-router-dom';
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
-import type { DLQGroup, DLQGroupPage, DLQResendResult } from 
'../../../api/message';
+import type { DLQGroup, DLQGroupPage, DLQMessagePage, DLQResendResult } from 
'../../../api/message';
 import { LangProvider } from '../../../i18n/LangContext';
 import * as messageService from '../../../services/messageService';
 import DLQPage from '../dlq';
@@ -30,6 +30,9 @@ vi.mock('../../../services/messageService', () => ({
   listDLQGroups: vi.fn(),
   resendDLQ: vi.fn(),
   exportDLQMessages: vi.fn(),
+  listDLQMessages: vi.fn(),
+  resendDLQSelected: vi.fn(),
+  exportDLQExcel: vi.fn(),
 }));
 vi.mock('../../../services/instanceService', () => ({
   listInstances: vi.fn().mockResolvedValue([
@@ -124,6 +127,9 @@ describe('DLQ page', () => {
     vi.mocked(messageService.listDLQGroups).mockReset();
     vi.mocked(messageService.resendDLQ).mockReset();
     vi.mocked(messageService.exportDLQMessages).mockReset();
+    vi.mocked(messageService.listDLQMessages).mockReset();
+    vi.mocked(messageService.resendDLQSelected).mockReset();
+    vi.mocked(messageService.exportDLQExcel).mockReset();
     createObjectURL = vi.fn().mockReturnValue('blob:dlq');
     revokeObjectURL = vi.fn();
     Object.defineProperty(URL, 'createObjectURL', {
@@ -136,6 +142,12 @@ describe('DLQ page', () => {
     });
     clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(() => {});
     
vi.mocked(messageService.listDLQGroups).mockResolvedValue(pageOf([dlqGroup]));
+    vi.mocked(messageService.listDLQMessages).mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      size: 20,
+    } satisfies DLQMessagePage);
   });
 
   afterEach(() => {
@@ -227,23 +239,24 @@ describe('DLQ page', () => {
     expect(screen.getByText('-')).toBeInTheDocument();
   });
 
-  it('opens a detail dialog with the selected group metadata', async () => {
+  it('opens a message detail drawer with the selected group metadata', async 
() => {
     const user = userEvent.setup();
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
-    await user.click(screen.getByRole('button', { name: /查看详情/ }));
+    await user.click(screen.getByRole('button', { name: /消息明细/ }));
 
-    expect(await screen.findByText('死信队列详情')).toBeInTheDocument();
-    expect(screen.getAllByText('cg-order')).toHaveLength(2);
-    expect(screen.getAllByText('%DLQ%cg-order')).toHaveLength(2);
-    expect(screen.getByText('ACTIVE')).toBeInTheDocument();
+    expect(await screen.findByText('DLQ 消息明细 · cg-order')).toBeInTheDocument();
+    
expect(screen.getAllByText('%DLQ%cg-order').length).toBeGreaterThanOrEqual(2);
+    expect(messageService.listDLQMessages).toHaveBeenCalledWith(
+      expect.objectContaining({ instanceId: 'instance-1', groupName: 
'cg-order' }),
+    );
   });
 
-  it('exports the dead-letter messages of a group as JSON', async () => {
-    vi.mocked(messageService.exportDLQMessages).mockResolvedValue({
-      blob: new 
Blob(['[{"msgId":"m1","topic":"%DLQ%cg-order","queueId":0,"offset":5}]'], {
-        type: 'application/json',
+  it('exports the dead-letter messages of a group as Excel', async () => {
+    vi.mocked(messageService.exportDLQExcel).mockResolvedValue({
+      blob: new Blob(['xlsx-bytes'], {
+        type: 
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
       }),
       meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
     });
@@ -253,8 +266,8 @@ describe('DLQ page', () => {
     await screen.findByText('cg-order');
     await user.click(screen.getByRole('button', { name: '导出' }));
 
-    expect(messageService.exportDLQMessages).toHaveBeenCalledTimes(1);
-    const exportParams = 
vi.mocked(messageService.exportDLQMessages).mock.calls[0][0];
+    expect(messageService.exportDLQExcel).toHaveBeenCalledTimes(1);
+    const exportParams = 
vi.mocked(messageService.exportDLQExcel).mock.calls[0][0];
     expect(exportParams.instanceId).toBe('instance-1');
     expect(exportParams.groupName).toBe('cg-order');
     expect(typeof exportParams.startTime).toBe('number');
@@ -262,15 +275,15 @@ describe('DLQ page', () => {
     // Default export window is the last day, mirroring the visible range 
picker.
     expect(exportParams.endTime! - exportParams.startTime!).toBeGreaterThan(23 
* 3600_000);
     expect(createObjectURL).toHaveBeenCalledTimes(1);
-    const blob = createObjectURL.mock.calls[0][0] as Blob;
-    await expect(blob.text()).resolves.toContain('"msgId":"m1"');
     expect(clickSpy).toHaveBeenCalledTimes(1);
     expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
   });
 
   it('warns when the export scan is incomplete', async () => {
-    vi.mocked(messageService.exportDLQMessages).mockResolvedValue({
-      blob: new Blob(['[]'], { type: 'application/json' }),
+    vi.mocked(messageService.exportDLQExcel).mockResolvedValue({
+      blob: new Blob([''], {
+        type: 
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      }),
       meta: { truncated: true, failedQueueCount: 2, limit: 100 },
     });
     const user = userEvent.setup();
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index d18319d97..919e8924e 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -25,6 +25,7 @@ import {
   Space,
   Flex,
   Modal,
+  Drawer,
   DatePicker,
   Typography,
   message,
@@ -34,10 +35,17 @@ import type { ColumnsType } from 'antd/es/table';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import PageHeader from '../../components/PageHeader';
+import InfoBanner from '../../components/InfoBanner';
 import { InstanceSelect } from '../../components/InstanceSelect';
 import { useLang } from '../../i18n/LangContext';
-import type { DLQGroup } from '../../api/message';
-import { exportDLQMessages, listDLQGroups, resendDLQ } from 
'../../services/messageService';
+import type { DLQGroup, DLQMessage } from '../../api/message';
+import {
+  exportDLQExcel,
+  listDLQGroups,
+  listDLQMessages,
+  resendDLQ,
+  resendDLQSelected,
+} from '../../services/messageService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 import { buildCsv, downloadBlob, downloadCsv, type CsvColumn } from 
'../../utils/download';
 import { tableScrollX } from '../../utils/table';
@@ -123,11 +131,22 @@ const DLQPage = () => {
   const [selectedGroupNames, setSelectedGroupNames] = useState<string[]>([]);
   const [loadError, setLoadError] = useState<string | null>(null);
   const [retryError, setRetryError] = useState<string | null>(null);
+  const [detailOpen, setDetailOpen] = useState(false);
+  const [detailMessages, setDetailMessages] = useState<DLQMessage[]>([]);
+  const [detailTotal, setDetailTotal] = useState(0);
+  const [detailPage, setDetailPage] = useState(1);
+  const [detailPageSize, setDetailPageSize] = useState(20);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [detailSelectedMsgIds, setDetailSelectedMsgIds] = 
useState<string[]>([]);
+  const [detailResending, setDetailResending] = useState(false);
+  const [detailError, setDetailError] = useState<string | null>(null);
+  const detailRequestIdRef = useRef(0);
   const retryRequestIdRef = useRef(0);
 
   useEffect(
     () => () => {
       retryRequestIdRef.current += 1;
+      detailRequestIdRef.current += 1;
     },
     [],
   );
@@ -266,13 +285,13 @@ const DLQPage = () => {
 
   const handleExport = async (group: DLQGroup) => {
     try {
-      const { blob, meta } = await exportDLQMessages({
+      const { blob, meta } = await exportDLQExcel({
         instanceId: selectedInstanceId,
         groupName: group.groupName,
         startTime: exportRange[0].valueOf(),
         endTime: exportRange[1].valueOf(),
       });
-      downloadBlob(blob, `${group.groupName}-dlq-messages.json`);
+      downloadBlob(blob, `${group.groupName}-dlq-messages.xlsx`);
       if (meta.truncated || meta.failedQueueCount > 0) {
         message.warning(
           `导出可能不完整:${meta.failedQueueCount} 个队列无法扫描,导出上限 ${meta.limit} 条`,
@@ -290,6 +309,97 @@ const DLQPage = () => {
     exportDLQGroups(selectedGroups, 'dlq-groups.csv');
   };
 
+  /* ─── DLQ Message Details Drawer ─── */
+  const openDetailDrawer = (group: DLQGroup) => {
+    setDetailGroup(group);
+    setDetailOpen(true);
+    setDetailPage(1);
+    setDetailSelectedMsgIds([]);
+    setDetailError(null);
+    void loadDetailMessages(group, 1, detailPageSize);
+  };
+
+  const loadDetailMessages = async (group: DLQGroup, page: number, pageSize: 
number) => {
+    if (!selectedInstanceId) return;
+    const requestId = detailRequestIdRef.current + 1;
+    detailRequestIdRef.current = requestId;
+    setDetailLoading(true);
+    setDetailError(null);
+    try {
+      const result = await listDLQMessages({
+        instanceId: selectedInstanceId,
+        groupName: group.groupName,
+        startTime: exportRange[0].valueOf(),
+        endTime: exportRange[1].valueOf(),
+        page,
+        pageSize,
+      });
+      if (detailRequestIdRef.current !== requestId) return;
+      setDetailMessages(result.items);
+      setDetailTotal(result.total);
+      setDetailPage(page);
+    } catch (error) {
+      if (detailRequestIdRef.current === requestId) {
+        setDetailError(getErrorMessage(error, '死信消息明细加载失败,请稍后重试'));
+      }
+    } finally {
+      if (detailRequestIdRef.current === requestId) {
+        setDetailLoading(false);
+      }
+    }
+  };
+
+  const resendSelectedMessages = async (msgIds: string[]) => {
+    if (!selectedInstanceId || !detailGroup || msgIds.length === 0) return;
+    setDetailResending(true);
+    setDetailError(null);
+    try {
+      const result = await resendDLQSelected({
+        instanceId: selectedInstanceId,
+        groupName: detailGroup.groupName,
+        msgIds,
+      });
+      if (result.outcome === 'FAILED' && result.failed > 0) {
+        message.error(`重发失败:成功 ${result.resent},失败 ${result.failed}`);
+      } else if (result.resent > 0 && result.failed > 0) {
+        message.warning(`重发部分完成:成功 ${result.resent},失败 ${result.failed}`);
+      } else {
+        message.success(`重发完成:成功 ${result.resent} 条`);
+      }
+      setDetailSelectedMsgIds([]);
+      await loadDetailMessages(detailGroup, detailPage, detailPageSize);
+    } catch (error) {
+      setDetailError(getErrorMessage(error, '重发死信消息失败,请稍后重试'));
+    } finally {
+      setDetailResending(false);
+    }
+  };
+
+  const exportDetailExcel = async () => {
+    if (!selectedInstanceId || !detailGroup) return;
+    try {
+      const { blob, meta } = await exportDLQExcel({
+        instanceId: selectedInstanceId,
+        groupName: detailGroup.groupName,
+        startTime: exportRange[0].valueOf(),
+        endTime: exportRange[1].valueOf(),
+        msgIds: detailSelectedMsgIds.length > 0 ? detailSelectedMsgIds : 
undefined,
+      });
+      downloadBlob(blob, `${detailGroup.groupName}-dlq-messages.xlsx`);
+      if (meta.truncated || meta.failedQueueCount > 0) {
+        message.warning(
+          `导出可能不完整:${meta.failedQueueCount} 个队列无法扫描,导出上限 ${meta.limit} 条`,
+        );
+      } else {
+        message.success(
+          `已导出 ${detailSelectedMsgIds.length > 0 ? `选中的 
${detailSelectedMsgIds.length} 条` : '全部'}死信消息(${blob.size} 字节)`,
+        );
+      }
+    } catch (error) {
+      message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
+    }
+  };
+
   /* ─── Table Columns ─── */
   const columns: ColumnsType<DLQGroup> = [
     {
@@ -361,9 +471,9 @@ const DLQPage = () => {
             size="small"
             icon={<Eye size={14} />}
             style={{ borderColor: '#1677ff', color: '#1677ff' }}
-            onClick={() => setDetailGroup(record)}
+            onClick={() => openDetailDrawer(record)}
           >
-            查看详情
+            消息明细
           </Button>
           <Button
             size="small"
@@ -388,6 +498,82 @@ const DLQPage = () => {
     },
   ];
 
+  /* ─── Detail Drawer Columns ─── */
+  const detailColumns: ColumnsType<DLQMessage> = [
+    {
+      title: 'Message ID',
+      dataIndex: 'msgId',
+      key: 'msgId',
+      width: 240,
+      render: (msgId: string) => (
+        <Text copyable style={{ fontFamily: 'monospace', fontSize: 14 }}>
+          {msgId}
+        </Text>
+      ),
+    },
+    {
+      title: 'Queue',
+      key: 'queue',
+      width: 90,
+      render: (_: unknown, record: DLQMessage) => (
+        <Text style={{ fontFamily: 'monospace' }}>{record.queueId}</Text>
+      ),
+    },
+    {
+      title: 'Offset',
+      dataIndex: 'offset',
+      key: 'offset',
+      width: 90,
+      render: (offset: number) => <Text style={{ fontFamily: 'monospace' 
}}>{offset}</Text>,
+    },
+    {
+      title: '入队时间',
+      dataIndex: 'storeTime',
+      key: 'storeTime',
+      width: 160,
+      render: (storeTime: number) => (
+        <Text style={{ fontFamily: 'monospace', fontSize: 14 }}>
+          {formatDateTime(new Date(storeTime).toISOString())}
+        </Text>
+      ),
+    },
+    {
+      title: 'Keys',
+      dataIndex: 'keys',
+      key: 'keys',
+      width: 140,
+      ellipsis: true,
+      render: (keys: string | null) => keys ?? '-',
+    },
+    {
+      title: 'Body',
+      dataIndex: 'body',
+      key: 'body',
+      width: 220,
+      ellipsis: true,
+      render: (body: string | null) => (
+        <Text type="secondary" style={{ fontSize: 14 }}>
+          {body && body.length > 80 ? `${body.slice(0, 80)}…` : body ?? '-'}
+        </Text>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'actions',
+      width: 100,
+      render: (_: unknown, record: DLQMessage) => (
+        <Button
+          size="small"
+          icon={<ArrowsCounterClockwise size={13} />}
+          loading={detailResending}
+          onClick={() => void resendSelectedMessages([record.msgId])}
+        >
+          重发
+        </Button>
+      ),
+    },
+  ];
+
   /* ═══════════════════════════════════════════
      Render
      ═══════════════════════════════════════════ */
@@ -575,72 +761,123 @@ const DLQPage = () => {
         )}
       </Modal>
 
-      <Modal
-        title="死信队列详情"
-        open={Boolean(detailGroup)}
-        onCancel={() => setDetailGroup(null)}
-        footer={<Button onClick={() => setDetailGroup(null)}>关闭</Button>}
-        width={560}
+      {/* ═══════════════════════════════════════════
+         Message Detail Drawer
+         ═══════════════════════════════════════════ */}
+      <Drawer
+        title={detailGroup ? `DLQ 消息明细 · ${detailGroup.groupName}` : 'DLQ 
消息明细'}
+        width={1080}
+        open={detailOpen}
+        onClose={() => {
+          detailRequestIdRef.current += 1;
+          setDetailOpen(false);
+          setDetailGroup(null);
+          setDetailMessages([]);
+          setDetailSelectedMsgIds([]);
+          setDetailError(null);
+        }}
         destroyOnHidden
       >
         {detailGroup && (
-          <div style={{ marginTop: 8 }}>
-            <div style={{ marginBottom: 16 }}>
-              <Text type="secondary" style={{ fontSize: 14, display: 'block', 
marginBottom: 4 }}>
-                Group 名称
-              </Text>
-              <Text strong copyable style={{ fontSize: 14, fontFamily: 
'monospace' }}>
-                {detailGroup.groupName}
-              </Text>
-            </div>
-            <div style={{ marginBottom: 16 }}>
-              <Text type="secondary" style={{ fontSize: 14, display: 'block', 
marginBottom: 4 }}>
-                DLQ Topic
-              </Text>
-              <Text copyable style={{ fontSize: 14, fontFamily: 'monospace' }}>
-                {detailGroup.dlqTopic}
-              </Text>
-            </div>
-            <Flex gap={24} wrap="wrap">
-              <div>
-                <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
-                  死信数量
-                </Text>
-                <Text
-                  strong
-                  style={{ color: detailGroup.messageCount > 0 ? '#fa8c16' : 
undefined }}
-                >
-                  {detailGroup.statsAvailable === false
-                    ? '不可用'
-                    : detailGroup.messageCount.toLocaleString()}
-                </Text>
-              </div>
-              <div>
-                <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
-                  重试次数
-                </Text>
-                <Text>{detailGroup.retryCount.toLocaleString()}</Text>
-              </div>
-              <div>
-                <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
-                  状态
-                </Text>
-                <Text>
-                  {detailGroup.statsAvailable === false ? '统计不可用' : 
detailGroup.status}
-                </Text>
-              </div>
+          <>
+            <Flex
+              justify="space-between"
+              align="flex-start"
+              wrap="wrap"
+              gap={12}
+              style={{ marginBottom: 16 }}
+            >
+              <Space size={24} wrap>
+                <div>
+                  <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
+                    DLQ Topic
+                  </Text>
+                  <Text copyable style={{ fontFamily: 'monospace' }}>
+                    {detailGroup.dlqTopic}
+                  </Text>
+                </div>
+                <div>
+                  <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
+                    死信数量
+                  </Text>
+                  <Text strong style={{ color: detailGroup.messageCount > 0 ? 
'#fa8c16' : undefined }}>
+                    {detailGroup.statsAvailable === false
+                      ? '不可用'
+                      : detailGroup.messageCount.toLocaleString()}
+                  </Text>
+                </div>
+                <div>
+                  <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
+                    最近入队时间
+                  </Text>
+                  <Text style={{ fontFamily: 'monospace' }}>
+                    {formatDateTime(detailGroup.lastEnqueueTime)}
+                  </Text>
+                </div>
+              </Space>
+              <Button
+                icon={<Download size={15} />}
+                disabled={detailTotal === 0}
+                onClick={() => void exportDetailExcel()}
+              >
+                {detailSelectedMsgIds.length > 0
+                  ? `导出选中 (${detailSelectedMsgIds.length})`
+                  : '导出全部'}
+              </Button>
             </Flex>
-            <div style={{ marginTop: 16 }}>
-              <Text type="secondary" style={{ fontSize: 14, display: 'block', 
marginBottom: 4 }}>
-                最近入队时间
-              </Text>
-              <Text style={{ fontFamily: 'monospace' }}>
-                {formatDateTime(detailGroup.lastEnqueueTime)}
-              </Text>
-            </div>
-          </div>
+
+            <InfoBanner
+              description={`明细按「导出时间范围」查询(${exportRange[0].format('YYYY-MM-DD 
HH:mm:ss')} ~ ${exportRange[1].format('YYYY-MM-DD HH:mm:ss')})。勾选后可单条或批量重发、导出 
Excel。`}
+            />
+
+            {detailError && (
+              <Alert showIcon type="warning" message={detailError} style={{ 
marginBottom: 16 }} />
+            )}
+
+            <Table<DLQMessage>
+              rowKey="msgId"
+              size="small"
+              loading={detailLoading}
+              dataSource={detailMessages}
+              rowSelection={{
+                selectedRowKeys: detailSelectedMsgIds,
+                onChange: (keys) => setDetailSelectedMsgIds(keys.map(String)),
+              }}
+              pagination={{
+                current: detailPage,
+                pageSize: detailPageSize,
+                total: detailTotal,
+                showSizeChanger: true,
+                pageSizeOptions: [10, 20, 50],
+                showTotal: (totalCount) => `共 ${totalCount} 条消息`,
+                onChange: (nextPage, nextPageSize) => {
+                  setDetailPage(nextPage);
+                  setDetailPageSize(nextPageSize);
+                  setDetailSelectedMsgIds([]);
+                  if (detailGroup) {
+                    void loadDetailMessages(detailGroup, nextPage, 
nextPageSize);
+                  }
+                },
+              }}
+              scroll={{ x: tableScrollX(detailColumns, { selection: true }) }}
+              columns={detailColumns}
+            />
+
+            {detailSelectedMsgIds.length > 0 && (
+              <Flex justify="flex-end" style={{ marginTop: 16 }}>
+                <Button
+                  type="primary"
+                  icon={<ArrowsCounterClockwise size={15} />}
+                  loading={detailResending}
+                  onClick={() => void 
resendSelectedMessages(detailSelectedMsgIds)}
+                >
+                  批量重发选中 ({detailSelectedMsgIds.length})
+                </Button>
+              </Flex>
+            )}
+          </>
         )}
-      </Modal>
+      </Drawer>
     </div>
   );
 };
diff --git a/web/src/services/messageService.ts 
b/web/src/services/messageService.ts
index 776fc3a64..a65aac08e 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -8,6 +8,7 @@ import type {
   TraceRecord,
   DLQGroup,
   DLQGroupPage,
+  DLQMessagePage,
   DLQResendResult,
   DLQExportMeta,
 } from '../api/message';
@@ -129,3 +130,47 @@ export async function exportDLQMessages(params: {
   }
   return messageApi.exportDLQMessages(params);
 }
+
+export async function listDLQMessages(params: {
+  instanceId: string;
+  groupName: string;
+  startTime?: number;
+  endTime?: number;
+  page?: number;
+  pageSize?: number;
+}): Promise<DLQMessagePage> {
+  if (isMockMode()) {
+    return { items: [], total: 0, page: params.page ?? 1, size: 
params.pageSize ?? 20 };
+  }
+  return messageApi.listDLQMessages(params);
+}
+
+export async function resendDLQSelected(data: {
+  instanceId: string;
+  groupName: string;
+  msgIds: string[];
+  targetTopic?: string;
+}): Promise<DLQResendResult> {
+  if (isMockMode()) {
+    return { matched: data.msgIds.length, resent: data.msgIds.length, failed: 
0, outcome: 'SUCCESS' };
+  }
+  return messageApi.resendDLQSelected(data);
+}
+
+export async function exportDLQExcel(params: {
+  instanceId: string;
+  groupName: string;
+  startTime?: number;
+  endTime?: number;
+  msgIds?: string[];
+}): Promise<{ blob: Blob; meta: DLQExportMeta }> {
+  if (isMockMode()) {
+    return {
+      blob: new Blob([''], {
+        type: 
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      }),
+      meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
+    };
+  }
+  return messageApi.exportDLQExcel(params);
+}

Reply via email to