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]