You are viewing a plain text version of this content. The canonical link for it is here.
Posted to server-dev@james.apache.org by bt...@apache.org on 2019/03/28 03:21:47 UTC

[james-project] 03/23: JAMES-2685 DMV Route export API

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

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

commit 9d8b47050eafc39f489d10ccc6dca805a5a5b3ef
Author: Tran Tien Duc <dt...@linagora.com>
AuthorDate: Thu Mar 14 17:10:36 2019 +0700

    JAMES-2685 DMV Route export API
---
 .../webadmin-mailbox-deleted-message-vault/pom.xml |  27 +
 .../routes/DeletedMessagesVaultExportTask.java     | 100 +++
 .../vault/routes/DeletedMessagesVaultRoutes.java   |  99 ++-
 .../james/webadmin/vault/routes/ExportService.java | 128 ++++
 .../routes/DeletedMessagesVaultRoutesTest.java     | 759 ++++++++++++++-------
 5 files changed, 841 insertions(+), 272 deletions(-)

diff --git a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/pom.xml b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/pom.xml
index 2c06be8..50adb89 100644
--- a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/pom.xml
+++ b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/pom.xml
@@ -71,12 +71,34 @@
         </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
+            <artifactId>blob-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>blob-memory</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>blob-export-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
             <artifactId>james-core</artifactId>
             <type>test-jar</type>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-data-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-data-memory</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
             <artifactId>james-server-webadmin-core</artifactId>
         </dependency>
         <dependency>
@@ -130,6 +152,11 @@
             <scope>test</scope>
         </dependency>
         <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
             <groupId>org.hamcrest</groupId>
             <artifactId>java-hamcrest</artifactId>
             <scope>test</scope>
diff --git a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultExportTask.java b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultExportTask.java
new file mode 100644
index 0000000..de4204b
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultExportTask.java
@@ -0,0 +1,100 @@
+/****************************************************************
+ * 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.vault.routes;
+
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.core.User;
+import org.apache.james.task.Task;
+import org.apache.james.task.TaskExecutionDetails;
+import org.apache.james.vault.search.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DeletedMessagesVaultExportTask implements Task {
+
+    public class AdditionalInformation implements TaskExecutionDetails.AdditionalInformation {
+
+        private final User userExportFrom;
+        private final MailAddress exportTo;
+        private final long totalExportedMessages;
+
+        public AdditionalInformation(User userExportFrom, MailAddress exportTo, long totalExportedMessages) {
+            this.userExportFrom = userExportFrom;
+            this.exportTo = exportTo;
+            this.totalExportedMessages = totalExportedMessages;
+        }
+
+        public String getUserExportFrom() {
+            return userExportFrom.asString();
+        }
+
+        public String getExportTo() {
+            return exportTo.asString();
+        }
+
+        public long getTotalExportedMessages() {
+            return totalExportedMessages;
+        }
+    }
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(DeletedMessagesVaultExportTask.class);
+
+    static final String TYPE = "deletedMessages/export";
+
+    private final ExportService exportService;
+    private final User userExportFrom;
+    private final Query exportQuery;
+    private final MailAddress exportTo;
+    private final AtomicLong totalExportedMessages;
+
+    DeletedMessagesVaultExportTask(ExportService exportService, User userExportFrom, Query exportQuery, MailAddress exportTo) {
+        this.exportService = exportService;
+        this.userExportFrom = userExportFrom;
+        this.exportQuery = exportQuery;
+        this.exportTo = exportTo;
+        this.totalExportedMessages = new AtomicLong();
+    }
+
+    @Override
+    public Result run() {
+        try {
+            Runnable messageToShareCallback = totalExportedMessages::incrementAndGet;
+            exportService.export(userExportFrom, exportQuery, exportTo, messageToShareCallback)
+                .block();
+            return Result.COMPLETED;
+        } catch (Exception e) {
+            LOGGER.error("Error happens when exporting deleted messages from {} to {}", userExportFrom.asString(), exportTo.asString());
+            return Result.PARTIAL;
+        }
+    }
+
+    @Override
+    public String type() {
+        return TYPE;
+    }
+
+    @Override
+    public Optional<TaskExecutionDetails.AdditionalInformation> details() {
+        return Optional.of(new AdditionalInformation(userExportFrom, exportTo, totalExportedMessages.get()));
+    }
+}
diff --git a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutes.java b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutes.java
index 527b8d9..ba8864c 100644
--- a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutes.java
+++ b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutes.java
@@ -26,16 +26,20 @@ import java.util.Optional;
 import java.util.stream.Stream;
 
 import javax.inject.Inject;
+import javax.mail.internet.AddressException;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
 
 import org.apache.commons.lang3.NotImplementedException;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.james.core.MailAddress;
 import org.apache.james.core.User;
 import org.apache.james.task.Task;
 import org.apache.james.task.TaskId;
 import org.apache.james.task.TaskManager;
+import org.apache.james.user.api.UsersRepository;
+import org.apache.james.user.api.UsersRepositoryException;
 import org.apache.james.vault.search.Query;
 import org.apache.james.webadmin.Constants;
 import org.apache.james.webadmin.Routes;
@@ -70,7 +74,8 @@ import spark.Service;
 public class DeletedMessagesVaultRoutes implements Routes {
 
     enum UserVaultAction {
-        RESTORE("restore");
+        RESTORE("restore"),
+        EXPORT("export");
 
         static Optional<UserVaultAction> getAction(String value) {
             Preconditions.checkNotNull(value, "action cannot be null");
@@ -102,21 +107,26 @@ public class DeletedMessagesVaultRoutes implements Routes {
     private static final String USER_PATH_PARAM = "user";
     private static final String RESTORE_PATH = ROOT_PATH + SEPARATOR + ":" + USER_PATH_PARAM;
     private static final String ACTION_QUERY_PARAM = "action";
+    private static final String EXPORT_TO_QUERY_PARAM = "exportTo";
 
     private final RestoreService vaultRestore;
+    private final ExportService vaultExport;
     private final JsonTransformer jsonTransformer;
     private final TaskManager taskManager;
     private final JsonExtractor<QueryElement> jsonExtractor;
     private final QueryTranslator queryTranslator;
+    private final UsersRepository usersRepository;
 
     @Inject
     @VisibleForTesting
-    DeletedMessagesVaultRoutes(RestoreService vaultRestore, JsonTransformer jsonTransformer,
-                               TaskManager taskManager, QueryTranslator queryTranslator) {
+    DeletedMessagesVaultRoutes(RestoreService vaultRestore, ExportService vaultExport, JsonTransformer jsonTransformer,
+                               TaskManager taskManager, QueryTranslator queryTranslator, UsersRepository usersRepository) {
         this.vaultRestore = vaultRestore;
+        this.vaultExport = vaultExport;
         this.jsonTransformer = jsonTransformer;
         this.taskManager = taskManager;
         this.queryTranslator = queryTranslator;
+        this.usersRepository = usersRepository;
         this.jsonExtractor = new JsonExtractor<>(QueryElement.class);
     }
 
@@ -149,11 +159,19 @@ public class DeletedMessagesVaultRoutes implements Routes {
             paramType = "query",
             example = "?action=restore",
             value = "Compulsory. Needs to be a valid action represent for an operation to perform on the Deleted Message Vault, " +
-                "valid action should be in the list (restore)")
+                "valid action should be in the list (restore, export)"),
+        @ApiImplicitParam(
+            dataType = "String",
+            name = "exportTo",
+            paramType = "query",
+            example = "?exportTo=user@james.org",
+            value = "Compulsory if action is export. Needs to be a valid mail address to represent for the destination " +
+                "where deleted messages content is export to")
     })
     @ApiResponses(value = {
         @ApiResponse(code = HttpStatus.CREATED_201, message = "Task is created", response = TaskIdDto.class),
         @ApiResponse(code = HttpStatus.BAD_REQUEST_400, message = "Bad request - user param is invalid"),
+        @ApiResponse(code = HttpStatus.NOT_FOUND_404, message = "Not found - requested user is not existed in the system"),
         @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500, message = "Internal server error - Something went bad on the server side.")
     })
     private TaskIdDto userActions(Request request, Response response) throws JsonExtractException {
@@ -165,22 +183,73 @@ public class DeletedMessagesVaultRoutes implements Routes {
     }
 
     private Task generateTask(UserVaultAction requestedAction, Request request) throws JsonExtractException {
-        User userToRestore = extractUser(request);
+        User user = extractUser(request);
+        validateUserExist(user);
         Query query = translate(jsonExtractor.parse(request.body()));
 
         switch (requestedAction) {
             case RESTORE:
-                return new DeletedMessagesVaultRestoreTask(vaultRestore, userToRestore, query);
+                return new DeletedMessagesVaultRestoreTask(vaultRestore, user, query);
+            case EXPORT:
+                return new DeletedMessagesVaultExportTask(vaultExport, user, query, extractMailAddress(request));
             default:
                 throw new NotImplementedException(requestedAction + " is not yet handled.");
         }
     }
 
+    private void validateUserExist(User user) {
+        try {
+            if (!usersRepository.contains(user.asString())) {
+                throw ErrorResponder.builder()
+                    .statusCode(HttpStatus.NOT_FOUND_404)
+                    .type(ErrorResponder.ErrorType.NOT_FOUND)
+                    .message("User '" + user.asString() + "' does not exist in the system")
+                    .haltError();
+            }
+        } catch (UsersRepositoryException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500)
+                .type(ErrorResponder.ErrorType.SERVER_ERROR)
+                .message("Unable to validate 'user' parameter")
+                .cause(e)
+                .haltError();
+        }
+    }
+
+    private MailAddress extractMailAddress(Request request) {
+        return Optional.ofNullable(request.queryParams(EXPORT_TO_QUERY_PARAM))
+            .filter(StringUtils::isNotBlank)
+            .map(this::parseToMailAddress)
+            .orElseThrow(() -> ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid 'exportTo' parameter, null or blank value is not accepted")
+                .haltError());
+    }
+
+    private MailAddress parseToMailAddress(String addressString) {
+        try {
+            return new MailAddress(addressString);
+        } catch (AddressException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid 'exportTo' parameter")
+                .cause(e)
+                .haltError();
+        }
+    }
+
     private Query translate(QueryElement queryElement) {
         try {
             return queryTranslator.translate(queryElement);
         } catch (QueryTranslator.QueryTranslatorException e) {
-            throw badRequest("Invalid payload passing to the route", e);
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid payload passing to the route")
+                .cause(e)
+                .haltError();
         }
     }
 
@@ -188,19 +257,15 @@ public class DeletedMessagesVaultRoutes implements Routes {
         try {
             return User.fromUsername(request.params(USER_PATH_PARAM));
         } catch (IllegalArgumentException e) {
-            throw badRequest("Invalid 'user' parameter", e);
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid 'user' parameter")
+                .cause(e)
+                .haltError();
         }
     }
 
-    private HaltException badRequest(String message, Exception e) {
-        return ErrorResponder.builder()
-            .statusCode(HttpStatus.BAD_REQUEST_400)
-            .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
-            .message(message)
-            .cause(e)
-            .haltError();
-    }
-
     private UserVaultAction extractUserVaultAction(Request request) {
         String actionParam = request.queryParams(ACTION_QUERY_PARAM);
         return Optional.ofNullable(actionParam)
diff --git a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/ExportService.java b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/ExportService.java
new file mode 100644
index 0000000..bab02b8
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/main/java/org/apache/james/webadmin/vault/routes/ExportService.java
@@ -0,0 +1,128 @@
+/****************************************************************
+ * 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.vault.routes;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.util.Collection;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import javax.inject.Inject;
+
+import org.apache.james.blob.api.BlobId;
+import org.apache.james.blob.api.BlobStore;
+import org.apache.james.blob.export.api.BlobExportMechanism;
+import org.apache.james.core.MailAddress;
+import org.apache.james.core.User;
+import org.apache.james.vault.DeletedMessage;
+import org.apache.james.vault.DeletedMessageVault;
+import org.apache.james.vault.DeletedMessageZipper;
+import org.apache.james.vault.search.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.fge.lambdas.Throwing;
+import com.github.fge.lambdas.functions.ThrowingFunction;
+import com.github.steveash.guavate.Guavate;
+import com.google.common.annotations.VisibleForTesting;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+class ExportService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ExportService.class);
+
+    private final BlobExportMechanism blobExport;
+    private final BlobStore blobStore;
+    private final DeletedMessageZipper zipper;
+    private final DeletedMessageVault vault;
+
+    @Inject
+    @VisibleForTesting
+    ExportService(BlobExportMechanism blobExport, BlobStore blobStore, DeletedMessageZipper zipper, DeletedMessageVault vault) {
+        this.blobExport = blobExport;
+        this.blobStore = blobStore;
+        this.zipper = zipper;
+        this.vault = vault;
+    }
+
+    Mono<Void> export(User user, Query exportQuery, MailAddress exportToAddress,
+                      Runnable messageToExportCallback) {
+
+        return matchingMessages(user, exportQuery)
+            .doOnNext(any -> messageToExportCallback.run())
+            .collect(Guavate.toImmutableList())
+            .map(Collection::stream)
+            .map(sneakyThrow(messages -> zipData(user, messages)))
+            .flatMap(sneakyThrow(zippedStream -> blobStore.save(zippedStream, zippedStream.available())))
+            .flatMap(blobId -> exportTo(user, exportToAddress, blobId))
+            .then();
+    }
+
+    private Flux<DeletedMessage> matchingMessages(User user, Query exportQuery) {
+        return Flux.from(vault.search(user, exportQuery))
+            .publishOn(Schedulers.elastic());
+    }
+
+    private PipedInputStream zipData(User user, Stream<DeletedMessage> messages) throws IOException {
+        PipedOutputStream outputStream = new PipedOutputStream();
+        PipedInputStream inputStream = new PipedInputStream();
+        inputStream.connect(outputStream);
+
+        asyncZipData(user, messages, outputStream).subscribe();
+
+        return inputStream;
+    }
+
+    private Mono<Void> asyncZipData(User user, Stream<DeletedMessage> messages, PipedOutputStream outputStream) {
+        return Mono.fromRunnable(Throwing.runnable(() -> zipper.zip(message -> loadMessageContent(user, message), messages, outputStream)).sneakyThrow())
+            .doOnSuccessOrError(Throwing.biConsumer((result, throwable) -> {
+                if (throwable != null) {
+                    LOGGER.error("Error happens when zipping deleted messages", throwable);
+                }
+                outputStream.flush();
+                outputStream.close();
+            }))
+            .subscribeOn(Schedulers.elastic())
+            .then();
+    }
+
+    private InputStream loadMessageContent(User user, DeletedMessage message) {
+        return Mono.from(vault.loadMimeMessage(user, message.getMessageId()))
+            .block();
+    }
+
+    private Mono<Void> exportTo(User user, MailAddress exportToAddress, BlobId blobId) {
+        return Mono.fromRunnable(() -> blobExport
+            .blobId(blobId)
+            .with(exportToAddress)
+            .explanation(String.format("Some deleted messages from user %s has been shared to you", user.asString()))
+            .export());
+    }
+
+    private <T, R> Function<T, R> sneakyThrow(ThrowingFunction<T, R> function) {
+        return Throwing.function(function).sneakyThrow();
+    }
+}
diff --git a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/test/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutesTest.java b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/test/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutesTest.java
index 3ed17dd..fcc563f 100644
--- a/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/test/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutesTest.java
+++ b/server/protocols/webadmin/webadmin-mailbox-deleted-message-vault/src/test/java/org/apache/james/webadmin/vault/routes/DeletedMessagesVaultRoutesTest.java
@@ -21,6 +21,7 @@ package org.apache.james.webadmin.vault.routes;
 
 import static io.restassured.RestAssured.given;
 import static io.restassured.RestAssured.when;
+import static io.restassured.RestAssured.with;
 import static org.apache.james.vault.DeletedMessageFixture.CONTENT;
 import static org.apache.james.vault.DeletedMessageFixture.DELETED_MESSAGE;
 import static org.apache.james.vault.DeletedMessageFixture.DELETED_MESSAGE_2;
@@ -44,17 +45,33 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.notNullValue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
 import java.util.stream.Stream;
 
+import org.apache.commons.configuration.DefaultConfigurationBuilder;
+import org.apache.james.blob.api.BlobId;
+import org.apache.james.blob.api.HashBlobId;
+import org.apache.james.blob.export.api.BlobExportMechanism;
+import org.apache.james.blob.memory.MemoryBlobStore;
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
 import org.apache.james.core.MaybeSender;
 import org.apache.james.core.User;
+import org.apache.james.dnsservice.api.DNSService;
+import org.apache.james.domainlist.lib.DomainListConfiguration;
+import org.apache.james.domainlist.memory.MemoryDomainList;
 import org.apache.james.mailbox.MailboxSession;
 import org.apache.james.mailbox.MessageManager;
 import org.apache.james.mailbox.exception.MailboxException;
@@ -71,7 +88,9 @@ import org.apache.james.mailbox.model.MultimailboxesSearchQuery;
 import org.apache.james.mailbox.model.SearchQuery;
 import org.apache.james.metrics.logger.DefaultMetricFactory;
 import org.apache.james.task.MemoryTaskManager;
+import org.apache.james.user.memory.MemoryUsersRepository;
 import org.apache.james.vault.DeletedMessage;
+import org.apache.james.vault.DeletedMessageZipper;
 import org.apache.james.vault.memory.MemoryDeletedMessagesVault;
 import org.apache.james.vault.search.Query;
 import org.apache.james.webadmin.WebAdminServer;
@@ -85,7 +104,8 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import com.google.common.collect.ImmutableList;
 
@@ -96,15 +116,33 @@ import reactor.core.publisher.Mono;
 
 class DeletedMessagesVaultRoutesTest {
 
+    private class NoopBlobExporting implements BlobExportMechanism {
+        @Override
+        public ShareeStage blobId(BlobId blobId) {
+            return exportTo -> explanation -> () -> export(exportTo, explanation);
+        }
+
+        void export(MailAddress exportTo, String explanation) {
+            // do nothing
+        }
+    }
+
     private static final String MATCH_ALL_QUERY = "{" +
         "\"combinator\": \"and\"," +
         "\"criteria\": []" +
         "}";
+    private static final Domain DOMAIN = Domain.of("apache.org");
 
     private WebAdminServer webAdminServer;
     private MemoryDeletedMessagesVault vault;
     private InMemoryMailboxManager mailboxManager;
     private MemoryTaskManager taskManager;
+    private NoopBlobExporting blobExporting;
+    private MemoryBlobStore blobStore;
+    private DeletedMessageZipper zipper;
+    private MemoryUsersRepository usersRepository;
+    private ExportService exportService;
+    private HashBlobId.Factory blobIdFactory;
 
     @BeforeEach
     void beforeEach() throws Exception {
@@ -116,11 +154,17 @@ class DeletedMessagesVaultRoutesTest {
         JsonTransformer jsonTransformer = new JsonTransformer();
 
         RestoreService vaultRestore = new RestoreService(vault, mailboxManager);
+        blobExporting = spy(new NoopBlobExporting());
+        blobIdFactory = new HashBlobId.Factory();
+        blobStore = new MemoryBlobStore(blobIdFactory);
+        zipper = new DeletedMessageZipper();
+        exportService = new ExportService(blobExporting, blobStore, zipper, vault);
         QueryTranslator queryTranslator = new QueryTranslator(new InMemoryId.Factory());
+        usersRepository = createUsersRepository();
         webAdminServer = WebAdminUtils.createWebAdminServer(
             new DefaultMetricFactory(),
             new TasksRoutes(taskManager, jsonTransformer),
-            new DeletedMessagesVaultRoutes(vaultRestore, jsonTransformer, taskManager, queryTranslator));
+            new DeletedMessagesVaultRoutes(vaultRestore, exportService, jsonTransformer, taskManager, queryTranslator, usersRepository));
 
         webAdminServer.configure(NO_CONFIGURATION);
         webAdminServer.await();
@@ -130,6 +174,23 @@ class DeletedMessagesVaultRoutesTest {
             .build();
     }
 
+    private MemoryUsersRepository createUsersRepository() throws Exception {
+        DNSService dnsService = mock(DNSService.class);
+        MemoryDomainList domainList = new MemoryDomainList(dnsService);
+        domainList.configure(DomainListConfiguration.builder()
+            .autoDetect(false)
+            .autoDetectIp(false));
+        domainList.addDomain(DOMAIN);
+
+        MemoryUsersRepository usersRepository = MemoryUsersRepository.withVirtualHosting();
+        usersRepository.setDomainList(domainList);
+        usersRepository.configure(new DefaultConfigurationBuilder());
+
+        usersRepository.addUser(USER.asString(), "userPassword");
+
+        return usersRepository;
+    }
+
     @AfterEach
     void afterEach() {
         webAdminServer.destroy();
@@ -137,6 +198,286 @@ class DeletedMessagesVaultRoutesTest {
     }
 
     @Nested
+    class UserVaultActionsValidationTest {
+
+        @Test
+        void userVaultAPIShouldReturnInvalidWhenActionIsMissing() {
+            when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @Test
+        void userVaultAPIShouldReturnInvalidWhenPassingEmptyAction() {
+            given()
+                .queryParam("action", "")
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @Test
+        void userVaultAPIShouldReturnInvalidWhenActionIsInValid() {
+            given()
+                .queryParam("action", "invalid action")
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @Test
+        void userVaultAPIShouldReturnInvalidWhenPassingCaseInsensitiveAction() {
+            given()
+                .queryParam("action", "RESTORE")
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnInvalidWhenUserIsInvalid(String action) {
+            given()
+                .queryParam("action", action)
+            .when()
+                .post("not@valid@user.com")
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnNotFoundWhenUserIsNotFoundInSystem(String action) {
+            given()
+                .queryParam("action", action)
+            .when()
+                .post(USER_2.asString())
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .body("statusCode", is(404))
+                .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType()))
+                .body("message", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnNotFoundWhenNoUserPathParameter(String action) {
+            given()
+                .queryParam("action", action)
+            .when()
+                .post()
+            .then()
+                .statusCode(HttpStatus.NOT_FOUND_404)
+                .body("statusCode", is(404))
+                .body("type", is(notNullValue()))
+                .body("message", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnBadRequestWhenPassingUnsupportedField(String action) throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+
+            String query =
+                "{" +
+                "  \"criteria\": [" +
+                "    {" +
+                "      \"fieldName\": \"unsupported\"," +
+                "      \"operator\": \"contains\"," +
+                "      \"value\": \"" + MAILBOX_ID_1.serialize() + "\"" +
+                "    }" +
+                "  ]" +
+                "}";
+
+            given()
+                .queryParam("action", action)
+                .body(query)
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnBadRequestWhenPassingUnsupportedOperator(String action) throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+
+            String query =
+                "{" +
+                "  \"criteria\": [" +
+                "    {" +
+                "      \"fieldName\": \"subject\"," +
+                "      \"operator\": \"isLongerThan\"," +
+                "      \"value\": \"" + SUBJECT + "\"" +
+                "    }" +
+                "  ]" +
+                "}";
+
+            given()
+                .queryParam("action", action)
+                .body(query)
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnBadRequestWhenPassingUnsupportedPairOfFieldNameAndOperator(String action) throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+
+            String query =
+                "{" +
+                "  \"criteria\": [" +
+                "    {" +
+                "      \"fieldName\": \"sender\"," +
+                "      \"operator\": \"contains\"," +
+                "      \"value\": \"" + SENDER.asString() + "\"" +
+                "    }" +
+                "  ]" +
+                "}";
+
+            given()
+                .queryParam("action", action)
+                .body(query)
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnBadRequestWhenPassingInvalidMailAddress(String action) throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+
+            String query =
+                "{" +
+                "  \"criteria\": [" +
+                "    {" +
+                "      \"fieldName\": \"sender\"," +
+                "      \"operator\": \"contains\"," +
+                "      \"value\": \"invalid@mail@domain.tld\"" +
+                "    }" +
+                "  ]" +
+                "}";
+
+            given()
+                .queryParam("action", action)
+                .body(query)
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnBadRequestWhenPassingOrCombinator(String action) throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+
+            String query =
+                "{" +
+                "  \"combinator\": \"or\"," +
+                "  \"criteria\": [" +
+                "    {" +
+                "      \"fieldName\": \"sender\"," +
+                "      \"operator\": \"contains\"," +
+                "      \"value\": \"" + SENDER.asString() + "\"" +
+                "    }" +
+                "  ]" +
+                "}";
+
+            given()
+                .queryParam("action", action)
+                .body(query)
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+
+        @ParameterizedTest
+        @ValueSource(strings = {"restore", "export"})
+        void userVaultAPIShouldReturnBadRequestWhenPassingNestedStructuredQuery(String action) throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+
+            String query =
+                "{" +
+                "  \"combinator\": \"and\"," +
+                "  \"criteria\": [" +
+                "    {" +
+                "      \"combinator\": \"or\"," +
+                "      \"criteria\": [" +
+                "        {\"fieldName\": \"subject\", \"operator\": \"containsIgnoreCase\", \"value\": \"Apache James\"}," +
+                "        {\"fieldName\": \"subject\", \"operator\": \"containsIgnoreCase\", \"value\": \"Apache James\"}" +
+                "      ]" +
+                "    }," +
+                "    {\"fieldName\": \"subject\", \"operator\": \"containsIgnoreCase\", \"value\": \"Apache James\"}" +
+                "  ]" +
+                "}";
+
+            given()
+                .queryParam("action", action)
+                .body(query)
+            .when()
+                .post(USER.asString())
+            .then()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .body("statusCode", is(400))
+                .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                .body("message", is(notNullValue()))
+                .body("details", is(notNullValue()));
+        }
+    }
+
+    @Nested
     class RestoreTest {
 
         @Nested
@@ -1083,258 +1424,6 @@ class DeletedMessagesVaultRoutesTest {
         }
 
         @Nested
-        class ValidationTest {
-
-            @Test
-            void restoreShouldReturnInvalidWhenActionIsMissing() {
-                when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnInvalidWhenPassingEmptyAction() {
-                given()
-                    .queryParam("action", "")
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnInvalidWhenActionIsInValid() {
-                given()
-                    .queryParam("action", "invalid action")
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnInvalidWhenPassingCaseInsensitiveAction() {
-                given()
-                    .queryParam("action", "RESTORE")
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnInvalidWhenUserIsInvalid() {
-                given()
-                    .queryParam("action", "restore")
-                .when()
-                    .post("not@valid@user.com")
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void postShouldReturnNotFoundWhenNoUserPathParameter() {
-                given()
-                    .queryParam("action", "restore")
-                .when()
-                    .post()
-                .then()
-                    .statusCode(HttpStatus.NOT_FOUND_404)
-                    .body("statusCode", is(404))
-                    .body("type", is(notNullValue()))
-                    .body("message", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnBadRequestWhenPassingUnsupportedField() throws Exception {
-                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
-
-                String query =
-                    "{" +
-                    "  \"criteria\": [" +
-                    "    {" +
-                    "      \"fieldName\": \"unsupported\"," +
-                    "      \"operator\": \"contains\"," +
-                    "      \"value\": \"" + MAILBOX_ID_1.serialize() + "\"" +
-                    "    }" +
-                    "  ]" +
-                    "}";
-
-                given()
-                    .body(query)
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnBadRequestWhenPassingUnsupportedOperator() throws Exception {
-                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
-
-                String query =
-                    "{" +
-                    "  \"criteria\": [" +
-                    "    {" +
-                    "      \"fieldName\": \"subject\"," +
-                    "      \"operator\": \"isLongerThan\"," +
-                    "      \"value\": \"" + SUBJECT + "\"" +
-                    "    }" +
-                    "  ]" +
-                    "}";
-
-                given()
-                    .body(query)
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnBadRequestWhenPassingUnsupportedPairOfFieldNameAndOperator() throws Exception {
-                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
-
-                String query =
-                    "{" +
-                    "  \"criteria\": [" +
-                    "    {" +
-                    "      \"fieldName\": \"sender\"," +
-                    "      \"operator\": \"contains\"," +
-                    "      \"value\": \"" + SENDER.asString() + "\"" +
-                    "    }" +
-                    "  ]" +
-                    "}";
-
-                given()
-                    .body(query)
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnBadRequestWhenPassingInvalidMailAddress() throws Exception {
-                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
-
-                String query =
-                    "{" +
-                    "  \"criteria\": [" +
-                    "    {" +
-                    "      \"fieldName\": \"sender\"," +
-                    "      \"operator\": \"contains\"," +
-                    "      \"value\": \"invalid@mail@domain.tld\"" +
-                    "    }" +
-                    "  ]" +
-                    "}";
-
-                given()
-                    .body(query)
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnBadRequestWhenPassingOrCombinator() throws Exception {
-                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
-
-                String query =
-                    "{" +
-                    "  \"combinator\": \"or\"," +
-                    "  \"criteria\": [" +
-                    "    {" +
-                    "      \"fieldName\": \"sender\"," +
-                    "      \"operator\": \"contains\"," +
-                    "      \"value\": \"" + SENDER.asString() + "\"" +
-                    "    }" +
-                    "  ]" +
-                    "}";
-
-                given()
-                    .body(query)
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-
-            @Test
-            void restoreShouldReturnBadRequestWhenPassingNestedStructuredQuery() throws Exception {
-                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
-
-                String query =
-                    "{" +
-                    "  \"combinator\": \"and\"," +
-                    "  \"criteria\": [" +
-                    "    {" +
-                    "      \"combinator\": \"or\"," +
-                    "      \"criteria\": [" +
-                    "        {\"fieldName\": \"subject\", \"operator\": \"containsIgnoreCase\", \"value\": \"Apache James\"}," +
-                    "        {\"fieldName\": \"subject\", \"operator\": \"containsIgnoreCase\", \"value\": \"Apache James\"}" +
-                    "      ]" +
-                    "    }," +
-                    "    {\"fieldName\": \"subject\", \"operator\": \"containsIgnoreCase\", \"value\": \"Apache James\"}" +
-                    "  ]" +
-                    "}";
-
-                given()
-                    .body(query)
-                .when()
-                    .post(USER.asString())
-                .then()
-                    .statusCode(HttpStatus.BAD_REQUEST_400)
-                    .body("statusCode", is(400))
-                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
-                    .body("message", is(notNullValue()))
-                    .body("details", is(notNullValue()));
-            }
-        }
-
-        @Nested
         class FailingRestoreTest {
 
             @Test
@@ -1376,7 +1465,7 @@ class DeletedMessagesVaultRoutesTest {
                 vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
                 vault.append(USER, DELETED_MESSAGE_2, new ByteArrayInputStream(CONTENT)).block();
 
-                MessageManager mockMessageManager = Mockito.mock(MessageManager.class);
+                MessageManager mockMessageManager = mock(MessageManager.class);
                 doReturn(mockMessageManager)
                     .when(mailboxManager)
                     .getMailbox(any(MailboxId.class), any(MailboxSession.class));
@@ -1597,6 +1686,166 @@ class DeletedMessagesVaultRoutesTest {
 
     }
 
+    @Nested
+    class ExportTest {
+
+        @Nested
+        class ValidationTest {
+
+            @Test
+            void exportShouldReturnBadRequestWhenExportToIsMissing() {
+                given()
+                    .queryParam("action", "export")
+                    .body(MATCH_ALL_QUERY)
+                .when()
+                    .post(USER.asString())
+                .then()
+                    .statusCode(HttpStatus.BAD_REQUEST_400)
+                    .body("statusCode", is(400))
+                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                    .body("message", is(notNullValue()));
+            }
+
+            @Test
+            void exportShouldReturnBadRequestWhenExportToIsInvalid() {
+                given()
+                    .queryParam("action", "export")
+                    .queryParam("exportTo", "export@to#me@")
+                    .body(MATCH_ALL_QUERY)
+                .when()
+                    .post(USER.asString())
+                .then()
+                    .statusCode(HttpStatus.BAD_REQUEST_400)
+                    .body("statusCode", is(400))
+                    .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+                    .body("message", is(notNullValue()))
+                    .body("details", is(notNullValue()));
+            }
+        }
+
+        @Nested
+        class TaskGeneratingTest {
+
+            @Test
+            void exportShouldReturnATaskCreated() {
+                given()
+                    .queryParam("action", "export")
+                    .queryParam("exportTo", "exportTo@james.org")
+                    .body(MATCH_ALL_QUERY)
+                .when()
+                    .post(USER.asString())
+                .then()
+                    .statusCode(HttpStatus.CREATED_201)
+                    .body("taskId", notNullValue());
+            }
+
+            @Test
+            void exportShouldProduceASuccessfulTaskWithInformation() {
+                vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+                vault.append(USER, DELETED_MESSAGE_2, new ByteArrayInputStream(CONTENT)).block();
+
+                String taskId =
+                    with()
+                        .queryParam("action", "export")
+                        .queryParam("exportTo", USER_2.asString())
+                        .body(MATCH_ALL_QUERY)
+                        .post(USER.asString())
+                    .jsonPath()
+                        .get("taskId");
+
+                given()
+                    .basePath(TasksRoutes.BASE)
+                .when()
+                    .get(taskId + "/await")
+                .then()
+                    .body("status", is("completed"))
+                    .body("taskId", is(taskId))
+                    .body("type", is(DeletedMessagesVaultExportTask.TYPE))
+                    .body("additionalInformation.userExportFrom", is(USER.asString()))
+                    .body("additionalInformation.exportTo", is(USER_2.asString()))
+                    .body("additionalInformation.totalExportedMessages", is(2))
+                    .body("startedDate", is(notNullValue()))
+                    .body("submitDate", is(notNullValue()))
+                    .body("completedDate", is(notNullValue()));
+            }
+        }
+
+        @Test
+        void exportShouldCallBlobExportingTargetToExportAddress() throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+            vault.append(USER, DELETED_MESSAGE_2, new ByteArrayInputStream(CONTENT)).block();
+
+            String taskId =
+                with()
+                    .queryParam("action", "export")
+                    .queryParam("exportTo", USER_2.asString())
+                    .body(MATCH_ALL_QUERY)
+                    .post(USER.asString())
+                .jsonPath()
+                    .get("taskId");
+
+            with()
+                .basePath(TasksRoutes.BASE)
+                .get(taskId + "/await");
+
+            verify(blobExporting, times(1))
+                .export(eq(USER_2.asMailAddress()), any());
+        }
+
+        @Test
+        void exportShouldNotDeleteMessagesInTheVault() {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+            vault.append(USER, DELETED_MESSAGE_2, new ByteArrayInputStream(CONTENT)).block();
+
+            String taskId =
+                with()
+                    .queryParam("action", "restore")
+                    .body(MATCH_ALL_QUERY)
+                    .post(USER.asString())
+                .jsonPath()
+                    .get("taskId");
+
+            with()
+                .basePath(TasksRoutes.BASE)
+                .get(taskId + "/await");
+
+            assertThat(Flux.from(vault.search(USER, Query.ALL)).toStream())
+                .containsOnly(DELETED_MESSAGE, DELETED_MESSAGE_2);
+        }
+
+        @Test
+        void exportShouldSaveDeletedMessagesDataToBlobStore() throws Exception {
+            vault.append(USER, DELETED_MESSAGE, new ByteArrayInputStream(CONTENT)).block();
+            vault.append(USER, DELETED_MESSAGE_2, new ByteArrayInputStream(CONTENT)).block();
+
+            String taskId =
+                with()
+                    .queryParam("action", "export")
+                    .queryParam("exportTo", USER_2.asString())
+                    .body(MATCH_ALL_QUERY)
+                    .post(USER.asString())
+                .jsonPath()
+                    .get("taskId");
+
+            with()
+                .basePath(TasksRoutes.BASE)
+                .get(taskId + "/await");
+
+            byte[] expectedZippedData = zippedMessagesData();
+
+            assertThat(blobStore.read(blobIdFactory.forPayload(expectedZippedData)))
+                .hasSameContentAs(new ByteArrayInputStream(expectedZippedData));
+        }
+
+        private byte[] zippedMessagesData() throws IOException {
+            ByteArrayOutputStream expectedZippedData = new ByteArrayOutputStream();
+            zipper.zip(message -> new ByteArrayInputStream(CONTENT),
+                Stream.of(DELETED_MESSAGE, DELETED_MESSAGE_2),
+                expectedZippedData);
+            return expectedZippedData.toByteArray();
+        }
+    }
+
     private boolean hasAnyMail(User user) throws MailboxException {
         MailboxSession session = mailboxManager.createSystemSession(user.asString());
         int limitToOneMessage = 1;


---------------------------------------------------------------------
To unsubscribe, e-mail: server-dev-unsubscribe@james.apache.org
For additional commands, e-mail: server-dev-help@james.apache.org