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

Arsnael pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/master by this push:
     new 9878e62b68 [ENHENCEMENT] Ease finding a Dead Letter event by eventId
9878e62b68 is described below

commit 9878e62b6821281f449d37c1decf5f03b398addb
Author: Benoit TELLIER <[email protected]>
AuthorDate: Sun Aug 23 22:04:38 2026 +0700

    [ENHENCEMENT] Ease finding a Dead Letter event by eventId
---
 .../modules/servers/partials/operate/webadmin.adoc |  31 ++++
 .../webadmin/routes/EventDeadLettersRoutes.java    |  58 +++++++-
 .../webadmin/service/EventDeadLettersService.java  |  24 ++++
 .../routes/EventDeadLettersRoutesTest.java         | 156 +++++++++++++++++++++
 src/site/markdown/server/manage-webadmin.md        |  26 ++++
 5 files changed, 294 insertions(+), 1 deletion(-)

diff --git a/docs/modules/servers/partials/operate/webadmin.adoc 
b/docs/modules/servers/partials/operate/webadmin.adoc
index d89e14f2dc..2a31b75cad 100644
--- a/docs/modules/servers/partials/operate/webadmin.adoc
+++ b/docs/modules/servers/partials/operate/webadmin.adoc
@@ -4532,6 +4532,37 @@ Response codes:
 * 400: Invalid group name or `insertionId`
 * 404: No event with this `insertionId`
 
+=== Searching an event by its event id
+
+....
+curl -XGET 
http://ip:port/events/deadLetter?eventId=6e0dd59d-660e-4d9b-b22f-0354479f47b4
+....
+
+Will look up the supplied `eventId` across all the groups holding dead
+lettered events, and return the full JSON associated with the first
+matching event. The group and the `insertionId` of the returned event are
+carried by the `X-Group` and `X-Insertion-Id` response headers, which can
+then be used to interact with the rest of this API (replay, deletion).
+
+An optional `group` query parameter narrows the search down to a single
+group, which is significantly cheaper when the group holding the event is
+known:
+
+....
+curl -XGET 
http://ip:port/events/deadLetter?eventId=6e0dd59d-660e-4d9b-b22f-0354479f47b4&group=org.apache.james.mailbox.events.EventBusTestFixture$GroupA
+....
+
+Note that this operation scans the dead letter content, thus can be slow
+when many events are dead lettered.
+
+Response codes:
+
+* 200: Success. A JSON representing this event is returned. `X-Group` and
+`X-Insertion-Id` headers locate it.
+* 400: Missing or invalid `eventId`, or invalid `group`
+* 404: No dead lettered event with this `eventId` (in this `group`, if
+supplied)
+
 === Deleting an event
 
 ....
diff --git 
a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/routes/EventDeadLettersRoutes.java
 
b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/routes/EventDeadLettersRoutes.java
index 3809171135..07e1be098e 100644
--- 
a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/routes/EventDeadLettersRoutes.java
+++ 
b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/routes/EventDeadLettersRoutes.java
@@ -21,8 +21,11 @@ package org.apache.james.webadmin.routes;
 
 import static 
org.apache.james.webadmin.service.EventDeadLettersRedeliverService.RunningOptions;
 
+import java.util.Optional;
+
 import jakarta.inject.Inject;
 
+import org.apache.james.events.Event;
 import org.apache.james.events.EventDeadLetters;
 import org.apache.james.events.EventSerializer;
 import org.apache.james.events.Group;
@@ -39,6 +42,8 @@ import org.apache.james.webadmin.utils.ParametersExtractor;
 import org.apache.james.webadmin.utils.Responses;
 import org.eclipse.jetty.http.HttpStatus;
 
+import com.google.common.base.Strings;
+
 import reactor.core.publisher.Mono;
 import spark.Request;
 import spark.Response;
@@ -50,6 +55,10 @@ public class EventDeadLettersRoutes implements Routes {
     public static final String BASE_PATH = "/events/deadLetter";
     private static final String GROUP_PARAM = ":group";
     private static final String INSERTION_ID_PARAMETER = ":insertionId";
+    private static final String EVENT_ID_QUERY_PARAMETER = "eventId";
+    private static final String GROUP_QUERY_PARAMETER = "group";
+    private static final String GROUP_HEADER = "X-Group";
+    private static final String INSERTION_ID_HEADER = "X-Insertion-Id";
     private static final TaskRegistrationKey RE_DELIVER = 
TaskRegistrationKey.of("reDeliver");
 
     private final EventDeadLettersService eventDeadLettersService;
@@ -74,6 +83,7 @@ public class EventDeadLettersRoutes implements Routes {
     @Override
     public void define(Service service) {
         service.post(BASE_PATH, performActionOnAllEvents(), jsonTransformer);
+        service.get(BASE_PATH, this::searchEvent);
         service.get(BASE_PATH + "/groups", this::listGroups, jsonTransformer);
         service.get(BASE_PATH + "/groups/" + GROUP_PARAM, 
this::listFailedEvents, jsonTransformer);
         service.post(BASE_PATH + "/groups/" + GROUP_PARAM, 
performActionOnGroupEvents(), jsonTransformer);
@@ -114,6 +124,23 @@ public class EventDeadLettersRoutes implements Routes {
             .block();
     }
 
+    private String searchEvent(Request request, Response response) {
+        Event.EventId eventId = parseEventId(request);
+
+        return parseGroupQueryParameter(request)
+            .map(group -> eventDeadLettersService.searchEvent(group, eventId))
+            .orElseGet(() -> eventDeadLettersService.searchEvent(eventId))
+            .map(deadLetteredEvent -> {
+                response.header(GROUP_HEADER, 
deadLetteredEvent.group().asString());
+                response.header(INSERTION_ID_HEADER, 
deadLetteredEvent.insertionId().asString());
+                return eventSerializer.toJson(deadLetteredEvent.event());
+            })
+            .filter(SerializationResult::isSuccess)
+            .map(SerializationResult::json)
+            .switchIfEmpty(Mono.fromRunnable(() -> 
response.status(HttpStatus.NOT_FOUND_404)))
+            .block();
+    }
+
     private String deleteEvent(Request request, Response response) {
         Group group = parseGroup(request);
         EventDeadLetters.InsertionId insertionId = parseInsertionId(request);
@@ -136,7 +163,10 @@ public class EventDeadLettersRoutes implements Routes {
     }
 
     private Group parseGroup(Request request) {
-        String groupAsString = request.params(GROUP_PARAM);
+        return deserializeGroup(request.params(GROUP_PARAM));
+    }
+
+    private static Group deserializeGroup(String groupAsString) {
         try {
             return Group.deserialize(groupAsString);
         } catch (Group.GroupDeserializationException e) {
@@ -163,6 +193,32 @@ public class EventDeadLettersRoutes implements Routes {
         }
     }
 
+    private Optional<Group> parseGroupQueryParameter(Request request) {
+        return 
Optional.ofNullable(Strings.emptyToNull(request.queryParams(GROUP_QUERY_PARAMETER)))
+            .map(EventDeadLettersRoutes::deserializeGroup);
+    }
+
+    private Event.EventId parseEventId(Request request) {
+        String eventIdAsString = request.queryParams(EVENT_ID_QUERY_PARAMETER);
+        if (Strings.isNullOrEmpty(eventIdAsString)) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .message("'%s' query parameter is compulsory", 
EVENT_ID_QUERY_PARAMETER)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .haltError();
+        }
+        try {
+            return Event.EventId.of(eventIdAsString);
+        } catch (Exception e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .message("Can not deserialize the supplied eventId: %s", 
eventIdAsString)
+                .cause(e)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .haltError();
+        }
+    }
+
     private RunningOptions parseRunningOptions(Request request) {
         return ParametersExtractor.extractPositiveInteger(request, "limit")
             .map(limit -> new RunningOptions(Limit.from(limit)))
diff --git 
a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/EventDeadLettersService.java
 
b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/EventDeadLettersService.java
index ab41e22329..486443f050 100644
--- 
a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/EventDeadLettersService.java
+++ 
b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/EventDeadLettersService.java
@@ -19,6 +19,8 @@
 
 package org.apache.james.webadmin.service;
 
+import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY;
+
 import java.util.List;
 import java.util.Set;
 import java.util.UUID;
@@ -35,9 +37,13 @@ import org.apache.james.task.Task;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 public class EventDeadLettersService {
+    public record DeadLetteredEvent(Group group, EventDeadLetters.InsertionId 
insertionId, Event event) {
+    }
+
     private final EventDeadLettersRedeliverService redeliverService;
     private final EventDeadLetters deadLetters;
     private final Set<Group> nonCriticalGroups;
@@ -71,6 +77,24 @@ public class EventDeadLettersService {
         return deadLetters.failedEvent(group, insertionId);
     }
 
+    public Mono<DeadLetteredEvent> searchEvent(Event.EventId eventId) {
+        return deadLetters.groupsWithFailedEvents()
+            .flatMap(group -> searchEventInGroup(group, eventId), 
DEFAULT_CONCURRENCY)
+            .next();
+    }
+
+    public Mono<DeadLetteredEvent> searchEvent(Group group, Event.EventId 
eventId) {
+        return searchEventInGroup(group, eventId)
+            .next();
+    }
+
+    private Flux<DeadLetteredEvent> searchEventInGroup(Group group, 
Event.EventId eventId) {
+        return deadLetters.failedIds(group)
+            .flatMap(insertionId -> deadLetters.failedEvent(group, insertionId)
+                .filter(event -> event.getEventId().equals(eventId))
+                .map(event -> new DeadLetteredEvent(group, insertionId, 
event)), DEFAULT_CONCURRENCY);
+    }
+
     public void deleteEvent(Group group, EventDeadLetters.InsertionId 
insertionId) {
         deadLetters.remove(group, insertionId).block();
     }
diff --git 
a/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/routes/EventDeadLettersRoutesTest.java
 
b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/routes/EventDeadLettersRoutesTest.java
index 29c651e5ed..a79de7f484 100644
--- 
a/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/routes/EventDeadLettersRoutesTest.java
+++ 
b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/routes/EventDeadLettersRoutesTest.java
@@ -323,6 +323,162 @@ class EventDeadLettersRoutesTest {
         }
     }
 
+    @Nested
+    class SearchEvent {
+        @Test
+        void searchEventShouldReturnEventAndItsLocation() {
+            InsertionId insertionId = deadLetters.store(new 
EventBusTestFixture.GroupA(), EVENT_1).block();
+
+            assertThat(insertionId).isNotNull();
+
+            String response = given()
+                .queryParam("eventId", UUID_1)
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.OK_200)
+                .contentType(ContentType.JSON)
+                .header("X-Group", SERIALIZED_GROUP_A)
+                .header("X-Insertion-Id", insertionId.asString())
+                .extract()
+                .asString();
+
+            assertThatJson(response).isEqualTo(JSON_1);
+        }
+
+        @Test
+        void searchEventShouldSearchAcrossAllGroups() {
+            deadLetters.store(new EventBusTestFixture.GroupA(), 
EVENT_1).block();
+            InsertionId insertionId = deadLetters.store(new 
EventBusTestFixture.GroupB(), EVENT_2).block();
+
+            assertThat(insertionId).isNotNull();
+
+            given()
+                .queryParam("eventId", UUID_2)
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.OK_200)
+                .header("X-Group", SERIALIZED_GROUP_B)
+                .header("X-Insertion-Id", insertionId.asString());
+        }
+
+        @Test
+        void searchEventShouldReturn404WhenNotFound() {
+            deadLetters.store(new EventBusTestFixture.GroupA(), 
EVENT_1).block();
+
+            given()
+                .queryParam("eventId", UUID_2)
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404);
+        }
+
+        @Test
+        void searchEventShouldReturn404WhenNoDeadLetteredEvent() {
+            given()
+                .queryParam("eventId", UUID_1)
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404);
+        }
+
+        @Test
+        void searchEventShouldFailWhenInvalidEventId() {
+            given()
+                .queryParam("eventId", "invalid")
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .body("statusCode", is(400))
+                .body("type", 
is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is("Can not deserialize the supplied eventId: 
invalid"));
+        }
+
+        @Test
+        void searchEventShouldReturnEventOfTheSuppliedGroup() {
+            InsertionId insertionId = deadLetters.store(new 
EventBusTestFixture.GroupA(), EVENT_1).block();
+
+            assertThat(insertionId).isNotNull();
+
+            String response = given()
+                .queryParam("eventId", UUID_1)
+                .queryParam("group", SERIALIZED_GROUP_A)
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.OK_200)
+                .contentType(ContentType.JSON)
+                .header("X-Group", SERIALIZED_GROUP_A)
+                .header("X-Insertion-Id", insertionId.asString())
+                .extract()
+                .asString();
+
+            assertThatJson(response).isEqualTo(JSON_1);
+        }
+
+        @Test
+        void searchEventShouldReturn404WhenEventBelongsToAnotherGroup() {
+            deadLetters.store(new EventBusTestFixture.GroupA(), 
EVENT_1).block();
+
+            given()
+                .queryParam("eventId", UUID_1)
+                .queryParam("group", SERIALIZED_GROUP_B)
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404);
+        }
+
+        @Test
+        void searchEventShouldFailWhenInvalidGroup() {
+            given()
+                .queryParam("eventId", UUID_1)
+                .queryParam("group", "invalid")
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .body("statusCode", is(400))
+                .body("type", 
is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is("Can not deserialize the supplied group: 
invalid"));
+        }
+
+        @Test
+        void searchEventShouldSearchAllGroupsWhenEmptyGroup() {
+            InsertionId insertionId = deadLetters.store(new 
EventBusTestFixture.GroupA(), EVENT_1).block();
+
+            assertThat(insertionId).isNotNull();
+
+            given()
+                .queryParam("eventId", UUID_1)
+                .queryParam("group", "")
+            .when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.OK_200)
+                .header("X-Group", SERIALIZED_GROUP_A)
+                .header("X-Insertion-Id", insertionId.asString());
+        }
+
+        @Test
+        void searchEventShouldFailWhenMissingEventId() {
+            when()
+                .get("/events/deadLetter")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .contentType(ContentType.JSON)
+                .body("statusCode", is(400))
+                .body("type", 
is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is("'eventId' query parameter is 
compulsory"));
+        }
+    }
+
     @Nested
     class Delete {
         @Test
diff --git a/src/site/markdown/server/manage-webadmin.md 
b/src/site/markdown/server/manage-webadmin.md
index 24773c9063..cc83a3f545 100644
--- a/src/site/markdown/server/manage-webadmin.md
+++ b/src/site/markdown/server/manage-webadmin.md
@@ -4652,6 +4652,32 @@ Response codes:
  - 400: Invalid group name or `insertionId`
  - 404: No event with this `insertionId`
 
+### Searching an event by its event id
+
+```
+curl -XGET 
http://ip:port/events/deadLetter?eventId=6e0dd59d-660e-4d9b-b22f-0354479f47b4
+```
+
+Will look up the supplied `eventId` across all the groups holding dead 
lettered events, and return the full JSON
+associated with the first matching event. The group and the `insertionId` of 
the returned event are carried by the
+`X-Group` and `X-Insertion-Id` response headers, which can then be used to 
interact with the rest of this API
+(replay, deletion).
+
+An optional `group` query parameter narrows the search down to a single group, 
which is significantly cheaper when
+the group holding the event is known:
+
+```
+curl -XGET 
http://ip:port/events/deadLetter?eventId=6e0dd59d-660e-4d9b-b22f-0354479f47b4&group=org.apache.james.mailbox.events.EventBusTestFixture$GroupA
+```
+
+Note that this operation scans the dead letter content, thus can be slow when 
many events are dead lettered.
+
+Response codes:
+
+ - 200: Success. A JSON representing this event is returned. `X-Group` and 
`X-Insertion-Id` headers locate it.
+ - 400: Missing or invalid `eventId`, or invalid `group`
+ - 404: No dead lettered event with this `eventId` (in this `group`, if 
supplied)
+
 ### Deleting an event
 
 ```


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to