JAMES-4181 Webadmin: Ability to move emails between repositories (#2954)
diff --git a/docs/modules/servers/partials/operate/webadmin.adoc b/docs/modules/servers/partials/operate/webadmin.adoc index d9cc8e5..da74695 100644 --- a/docs/modules/servers/partials/operate/webadmin.adoc +++ b/docs/modules/servers/partials/operate/webadmin.adoc
@@ -4136,6 +4136,61 @@ } .... +=== Moving mails from a mail repository to another + +To move all mails from one repository to another: + +.... +curl -XPATCH http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails \ + -d '{"mailRepository": "/var/mail/error-saved"}' \ + -H "Content-Type: application/json" +.... + +Resource name `encodedPathOfTheRepository` should be the URL-encoded path of an existing mail +repository. The request body must contain the path of an existing target mail repository. + +For instance: + +.... +curl -XPATCH http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails \ + -d '{"mailRepository": "var/mail/error-saved"}' \ + -H "Content-Type: application/json" +.... + +Response codes: + +* 204: Mails were successfully moved. +* 400: The target repository does not exist, or the request body is invalid. +* 404: The source repository does not exist. + +=== Moving a specific mail to another repository + +To move a specific mail from one repository to another: + +.... +curl -XPATCH http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/{mailKey} \ + -d '{"mailRepository": "/var/mail/error-saved"}' \ + -H "Content-Type: application/json" +.... + +Resource name `encodedPathOfTheRepository` should be the URL-encoded path of an existing mail +repository. Resource name `mailKey` should be the key of a mail stored in that repository. +The request body must contain the path of an existing target mail repository. + +For instance: + +.... +curl -XPATCH http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/name1 \ + -d '{"mailRepository": "var/mail/error-saved"}' \ + -H "Content-Type: application/json" +.... + +Response codes: + +* 204: The mail was successfully moved (or the mail key was not found, in which case nothing is done). +* 400: The target repository does not exist, or the request body is invalid. +* 404: The source repository does not exist. + == Administrating mail queues === Listing mail queues
diff --git a/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/dto/MoveMailRepositoryRequest.java b/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/dto/MoveMailRepositoryRequest.java new file mode 100644 index 0000000..c7d31ff --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/dto/MoveMailRepositoryRequest.java
@@ -0,0 +1,36 @@ +/**************************************************************** + * 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.james.webadmin.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MoveMailRepositoryRequest { + private final String mailRepository; + + @JsonCreator + public MoveMailRepositoryRequest(@JsonProperty("mailRepository") String mailRepository) { + this.mailRepository = mailRepository; + } + + public String getMailRepository() { + return mailRepository; + } +}
diff --git a/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/routes/MailRepositoriesRoutes.java b/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/routes/MailRepositoriesRoutes.java index 2777aea..c1a986c 100644 --- a/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/routes/MailRepositoriesRoutes.java +++ b/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/routes/MailRepositoriesRoutes.java
@@ -52,6 +52,7 @@ import org.apache.james.webadmin.dto.InaccessibleFieldException; import org.apache.james.webadmin.dto.MailDto; import org.apache.james.webadmin.dto.MailDto.AdditionalField; +import org.apache.james.webadmin.dto.MoveMailRepositoryRequest; import org.apache.james.webadmin.service.MailRepositoryStoreService; import org.apache.james.webadmin.service.ReprocessingAllMailsTask; import org.apache.james.webadmin.service.ReprocessingOneMailTask; @@ -61,6 +62,8 @@ import org.apache.james.webadmin.tasks.TaskRegistrationKey; import org.apache.james.webadmin.utils.ErrorResponder; import org.apache.james.webadmin.utils.ErrorResponder.ErrorType; +import org.apache.james.webadmin.utils.JsonExtractException; +import org.apache.james.webadmin.utils.JsonExtractor; import org.apache.james.webadmin.utils.JsonTransformer; import org.apache.james.webadmin.utils.ParametersExtractor; import org.apache.james.webadmin.utils.Responses; @@ -73,12 +76,16 @@ import spark.HaltException; import spark.Request; +import spark.Response; +import spark.Route; import spark.Service; public class MailRepositoriesRoutes implements Routes { public static final String MAIL_REPOSITORIES = "mailRepositories"; private static final TaskRegistrationKey REPROCESS_ACTION = TaskRegistrationKey.of("reprocess"); + private static final JsonExtractor<MoveMailRepositoryRequest> MOVE_REQUEST_EXTRACTOR = + new JsonExtractor<>(MoveMailRepositoryRequest.class); private final JsonTransformer jsonTransformer; private final MailRepositoryStoreService repositoryStoreService; @@ -117,9 +124,9 @@ defineDeleteAll(); - defineReprocessAll(); + definePatchAll(); - defineReprocessOne(); + definePatchOne(); } public void definePutMailRepository() { @@ -328,11 +335,14 @@ service.delete(MAIL_REPOSITORIES + "/:encodedPath/mails", taskFromRequest.asRoute(taskManager), jsonTransformer); } - public void defineReprocessAll() { - service.patch(MAIL_REPOSITORIES + "/:encodedPath/mails", - TaskFromRequestRegistry.of(REPROCESS_ACTION, this::reprocessAll) - .asRoute(taskManager), - jsonTransformer); + public void definePatchAll() { + Route reprocessRoute = TaskFromRequestRegistry.of(REPROCESS_ACTION, this::reprocessAll).asRoute(taskManager); + service.patch(MAIL_REPOSITORIES + "/:encodedPath/mails", (request, response) -> { + if (hasMoveRequestBody(request)) { + return moveAllMails(request, response); + } + return reprocessRoute.handle(request, response); + }, jsonTransformer); } private Task reprocessAll(Request request) throws MailRepositoryStore.MailRepositoryStoreException { @@ -351,11 +361,14 @@ parseLimit(request)); } - public void defineReprocessOne() { - service.patch(MAIL_REPOSITORIES + "/:encodedPath/mails/:key", - TaskFromRequestRegistry.of(REPROCESS_ACTION, this::reprocessOne) - .asRoute(taskManager), - jsonTransformer); + public void definePatchOne() { + Route reprocessOneRoute = TaskFromRequestRegistry.of(REPROCESS_ACTION, this::reprocessOne).asRoute(taskManager); + service.patch(MAIL_REPOSITORIES + "/:encodedPath/mails/:key", (request, response) -> { + if (hasMoveRequestBody(request)) { + return moveOneMail(request, response); + } + return reprocessOneRoute.handle(request, response); + }, jsonTransformer); } private Task reprocessOne(Request request) { @@ -365,6 +378,87 @@ return new ReprocessingOneMailTask(reprocessingService, path, extractConfiguration(request), key, Clock.systemUTC()); } + private boolean hasMoveRequestBody(Request request) { + String body = request.body(); + return body != null && !body.isBlank(); + } + + private Object moveAllMails(Request request, Response response) { + MailRepositoryPath sourcePath = getRepositoryPath(request); + MoveMailRepositoryRequest moveRequest = parseMoveRequest(request); + MailRepositoryPath targetPath = MailRepositoryPath.from(moveRequest.getMailRepository()); + try { + if (!repositoryStoreService.repositoryExists(sourcePath)) { + throw repositoryNotFound(request.params("encodedPath"), sourcePath); + } + if (!repositoryStoreService.repositoryExists(targetPath)) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.BAD_REQUEST_400) + .type(ErrorType.INVALID_ARGUMENT) + .message("The target repository '%s' does not exist", moveRequest.getMailRepository()) + .haltError(); + } + repositoryStoreService.moveAllMails(sourcePath, targetPath); + return Responses.returnNoContent(response); + } catch (MailRepositoryStore.MailRepositoryStoreException | MessagingException e) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .type(ErrorType.SERVER_ERROR) + .cause(e) + .message("Error while moving mails") + .haltError(); + } + } + + private Object moveOneMail(Request request, Response response) { + MailRepositoryPath sourcePath = getRepositoryPath(request); + MailKey mailKey = new MailKey(request.params("key")); + MoveMailRepositoryRequest moveRequest = parseMoveRequest(request); + MailRepositoryPath targetPath = MailRepositoryPath.from(moveRequest.getMailRepository()); + try { + if (!repositoryStoreService.repositoryExists(sourcePath)) { + throw repositoryNotFound(request.params("encodedPath"), sourcePath); + } + if (!repositoryStoreService.repositoryExists(targetPath)) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.BAD_REQUEST_400) + .type(ErrorType.INVALID_ARGUMENT) + .message("The target repository '%s' does not exist", moveRequest.getMailRepository()) + .haltError(); + } + repositoryStoreService.moveMail(sourcePath, targetPath, mailKey); + return Responses.returnNoContent(response); + } catch (MailRepositoryStore.MailRepositoryStoreException | MessagingException e) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .type(ErrorType.SERVER_ERROR) + .cause(e) + .message("Error while moving mail") + .haltError(); + } + } + + private MoveMailRepositoryRequest parseMoveRequest(Request request) { + try { + MoveMailRepositoryRequest moveRequest = MOVE_REQUEST_EXTRACTOR.parse(request.body()); + if (moveRequest.getMailRepository() == null || moveRequest.getMailRepository().isBlank()) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.BAD_REQUEST_400) + .type(ErrorType.INVALID_ARGUMENT) + .message("'mailRepository' field is mandatory in request body") + .haltError(); + } + return moveRequest; + } catch (JsonExtractException e) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.BAD_REQUEST_400) + .type(ErrorType.INVALID_ARGUMENT) + .cause(e) + .message("Invalid JSON body") + .haltError(); + } + } + private Set<AdditionalField> extractAdditionalFields(String additionalFieldsParam) throws IllegalArgumentException { return Splitter .on(',')
diff --git a/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/service/MailRepositoryStoreService.java b/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/service/MailRepositoryStoreService.java index e386234..2099a9ab 100644 --- a/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/service/MailRepositoryStoreService.java +++ b/server/protocols/webadmin/webadmin-mailrepository/src/main/java/org/apache/james/webadmin/service/MailRepositoryStoreService.java
@@ -29,6 +29,7 @@ import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import org.apache.commons.lang3.tuple.Pair; import org.apache.james.lifecycle.api.LifecycleUtil; import org.apache.james.mailrepository.api.MailKey; import org.apache.james.mailrepository.api.MailRepository; @@ -116,6 +117,40 @@ .forEach(Throwing.consumer((MailRepository repository) -> repository.remove(mailKey)).sneakyThrow()); } + public boolean repositoryExists(MailRepositoryPath path) throws MailRepositoryStore.MailRepositoryStoreException { + return mailRepositoryStore.getByPath(path).findAny().isPresent(); + } + + public void moveMail(MailRepositoryPath sourcePath, MailRepositoryPath targetPath, MailKey mailKey) throws MailRepositoryStore.MailRepositoryStoreException, MessagingException { + MailRepository source = getRepositories(sourcePath).findFirst() + .orElseThrow(() -> new MailRepositoryStore.MailRepositoryStoreException("No repository found for path: " + sourcePath.asString())); + MailRepository target = getRepositories(targetPath).findFirst() + .orElseThrow(() -> new MailRepositoryStore.MailRepositoryStoreException("No repository found for path: " + targetPath.asString())); + + moveMail(source, target, mailKey); + } + + public void moveAllMails(MailRepositoryPath sourcePath, MailRepositoryPath targetPath) throws MailRepositoryStore.MailRepositoryStoreException, MessagingException { + MailRepository target = getRepositories(targetPath).findFirst() + .orElseThrow(() -> new MailRepositoryStore.MailRepositoryStoreException("No repository found for path: " + targetPath.asString())); + + getRepositories(sourcePath) + .flatMap(Throwing.function(repo -> Iterators.toStream(repo.list()).map(key -> Pair.of(repo, key)))) + .forEach(Throwing.<Pair<MailRepository, MailKey>>consumer(pair -> moveMail(pair.getKey(), target, pair.getValue())).sneakyThrow()); + } + + private void moveMail(MailRepository source, MailRepository target, MailKey key) throws MessagingException { + Optional.ofNullable(source.retrieve(key)) + .ifPresent(Throwing.consumer(mail -> { + try { + target.store(mail); + source.remove(key); + } finally { + LifecycleUtil.dispose(mail); + } + })); + } + public Task createClearMailRepositoryTask(MailRepositoryPath path) throws MailRepositoryStore.MailRepositoryStoreException, MessagingException { getRepositories(path); return new ClearMailRepositoryTask(mailRepositoryStore, path);
diff --git a/server/protocols/webadmin/webadmin-mailrepository/src/test/java/org/apache/james/webadmin/routes/MailRepositoriesRoutesTest.java b/server/protocols/webadmin/webadmin-mailrepository/src/test/java/org/apache/james/webadmin/routes/MailRepositoriesRoutesTest.java index 35b177f..133e9f5 100644 --- a/server/protocols/webadmin/webadmin-mailrepository/src/test/java/org/apache/james/webadmin/routes/MailRepositoriesRoutesTest.java +++ b/server/protocols/webadmin/webadmin-mailrepository/src/test/java/org/apache/james/webadmin/routes/MailRepositoriesRoutesTest.java
@@ -2358,6 +2358,207 @@ .body("status", is("failed")); } + @Test + void moveAllMailsShouldReturn404WhenSourceRepositoryDoesNotExist() { + given() + .body("{\"mailRepository\": \"myTargetRepo\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails") + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body("statusCode", is(404)) + .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType())); + } + + @Test + void moveAllMailsShouldReturn400WhenTargetRepositoryDoesNotExist() throws Exception { + mailRepositoryStore.create(URL_MY_REPO); + + given() + .body("{\"mailRepository\": \"nonExistingTarget\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("The target repository 'nonExistingTarget' does not exist")); + } + + @Test + void moveAllMailsShouldReturn400WhenMailRepositoryFieldIsMissing() throws Exception { + mailRepositoryStore.create(URL_MY_REPO); + + given() + .body("{}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("'mailRepository' field is mandatory in request body")); + } + + @Test + void moveAllMailsShouldReturn400WhenBodyIsInvalidJson() throws Exception { + mailRepositoryStore.create(URL_MY_REPO); + + given() + .body("not-json") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid JSON body")); + } + + @Test + void moveAllMailsShouldMoveMailsFromSourceToTarget() throws Exception { + MailRepository sourceRepo = mailRepositoryStore.create(URL_MY_REPO); + MailRepository targetRepo = mailRepositoryStore.create(MailRepositoryUrl.from("memory://myTargetRepo")); + + sourceRepo.store(FakeMail.builder().name(NAME_1).build()); + sourceRepo.store(FakeMail.builder().name(NAME_2).build()); + + given() + .body("{\"mailRepository\": \"myTargetRepo\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails") + .then() + .statusCode(HttpStatus.NO_CONTENT_204); + + assertThat(sourceRepo.list()).toIterable().isEmpty(); + assertThat(targetRepo.list()).toIterable() + .containsExactlyInAnyOrder(new MailKey(NAME_1), new MailKey(NAME_2)); + } + + @Test + void moveAllMailsShouldReturn204WhenSourceRepositoryIsEmpty() throws Exception { + mailRepositoryStore.create(URL_MY_REPO); + mailRepositoryStore.create(MailRepositoryUrl.from("memory://myTargetRepo")); + + given() + .body("{\"mailRepository\": \"myTargetRepo\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails") + .then() + .statusCode(HttpStatus.NO_CONTENT_204); + } + + @Test + void moveOneMailShouldReturn404WhenSourceRepositoryDoesNotExist() { + given() + .body("{\"mailRepository\": \"myTargetRepo\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails/" + NAME_1) + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body("statusCode", is(404)) + .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType())); + } + + @Test + void moveOneMailShouldReturn400WhenTargetRepositoryDoesNotExist() throws Exception { + MailRepository sourceRepo = mailRepositoryStore.create(URL_MY_REPO); + sourceRepo.store(FakeMail.builder().name(NAME_1).build()); + + given() + .body("{\"mailRepository\": \"nonExistingTarget\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails/" + NAME_1) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("The target repository 'nonExistingTarget' does not exist")); + } + + @Test + void moveOneMailShouldReturn400WhenMailRepositoryFieldIsMissing() throws Exception { + MailRepository sourceRepo = mailRepositoryStore.create(URL_MY_REPO); + sourceRepo.store(FakeMail.builder().name(NAME_1).build()); + + given() + .body("{}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails/" + NAME_1) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("'mailRepository' field is mandatory in request body")); + } + + @Test + void moveOneMailShouldReturn400WhenBodyIsInvalidJson() throws Exception { + MailRepository sourceRepo = mailRepositoryStore.create(URL_MY_REPO); + sourceRepo.store(FakeMail.builder().name(NAME_1).build()); + + given() + .body("not-json") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails/" + NAME_1) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid JSON body")); + } + + @Test + void moveOneMailShouldMoveMailFromSourceToTarget() throws Exception { + MailRepository sourceRepo = mailRepositoryStore.create(URL_MY_REPO); + MailRepository targetRepo = mailRepositoryStore.create(MailRepositoryUrl.from("memory://myTargetRepo")); + + sourceRepo.store(FakeMail.builder().name(NAME_1).build()); + sourceRepo.store(FakeMail.builder().name(NAME_2).build()); + + given() + .body("{\"mailRepository\": \"myTargetRepo\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails/" + NAME_1) + .then() + .statusCode(HttpStatus.NO_CONTENT_204); + + assertThat(sourceRepo.list()).toIterable() + .containsOnly(new MailKey(NAME_2)); + assertThat(targetRepo.list()).toIterable() + .containsOnly(new MailKey(NAME_1)); + } + + @Test + void moveOneMailShouldReturn204WhenMailKeyDoesNotExist() throws Exception { + MailRepository sourceRepo = mailRepositoryStore.create(URL_MY_REPO); + mailRepositoryStore.create(MailRepositoryUrl.from("memory://myTargetRepo")); + + sourceRepo.store(FakeMail.builder().name(NAME_1).build()); + + given() + .body("{\"mailRepository\": \"myTargetRepo\"}") + .contentType(io.restassured.http.ContentType.JSON) + .when() + .patch(PATH_ESCAPED_MY_REPO + "/mails/unknown") + .then() + .statusCode(HttpStatus.NO_CONTENT_204); + + assertThat(sourceRepo.list()).toIterable() + .containsOnly(new MailKey(NAME_1)); + } + private void createMailRepositoryStore() throws Exception { MemoryMailRepositoryUrlStore urlStore = new MemoryMailRepositoryUrlStore(); MailRepositoryStoreConfiguration configuration = MailRepositoryStoreConfiguration.forItems(