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(