This is an automated email from the ASF dual-hosted git repository. Arsnael pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 331116eb6148b1508a3d9c3e79ed5a1bdadd3e83 Author: Rene Cordier <[email protected]> AuthorDate: Fri May 15 16:11:38 2026 +0700 JAMES-4204 Webadmin endpoint for restore mailbox backup --- .../modules/servers/partials/operate/webadmin.adoc | 29 ++- .../james/mailbox/backup/DefaultMailboxBackup.java | 1 + .../james/CassandraRabbitMQJamesServerMain.java | 4 +- .../james/DistributedPOP3JamesServerMain.java | 4 +- .../org/apache/james/MemoryJamesServerMain.java | 4 +- .../org/apache/james/PostgresJamesServerMain.java | 4 +- ...odule.java => MailboxesBackupRoutesModule.java} | 13 +- ...adminMailboxBackupTaskSerializationModule.java} | 22 +- .../integration/WebAdminServerIntegrationTest.java | 28 +++ .../service/MailboxesRestoreRequestToTask.java | 74 +++++++ .../webadmin/service/MailboxesRestoreTask.java | 88 ++++++++ ...ilboxesRestoreTaskAdditionalInformationDTO.java | 71 ++++++ .../webadmin/service/MailboxesRestoreTaskDTO.java | 75 +++++++ .../james/webadmin/service/RestoreService.java | 79 +++++++ .../service/MailboxesRestoreRequestToTaskTest.java | 240 +++++++++++++++++++++ ...esRestoreTaskAdditionalInformationDTOTest.java} | 34 ++- .../MailboxesRestoreTaskSerializationTest.java} | 40 ++-- .../james/webadmin/service/RestoreServiceTest.java | 195 +++++++++++++++++ .../mailboxesRestore.additionalInformation.json | 5 + .../test/resources/json/mailboxesRestore.task.json | 5 + src/site/markdown/server/manage-webadmin.md | 28 ++- 21 files changed, 993 insertions(+), 50 deletions(-) diff --git a/docs/modules/servers/partials/operate/webadmin.adoc b/docs/modules/servers/partials/operate/webadmin.adoc index 442861e3ae..0a3cc30f44 100644 --- a/docs/modules/servers/partials/operate/webadmin.adoc +++ b/docs/modules/servers/partials/operate/webadmin.adoc @@ -1737,7 +1737,7 @@ Response codes: === Exporting user mailboxes .... -curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?action=export +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=export .... Resource name `usernameToBeUsed` should be an existing user @@ -1759,6 +1759,33 @@ and the following `additionalInformation`: } .... +=== Restoring user mailboxes + +.... +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=restore --data-binary @backup.zip +.... + +Resource name `usernameToBeUsed` should be an existing user. The request body must contain the ZIP backup data. + +Response codes: + +* 201: Success. Corresponding task id is returned +* 400: The request body is empty +* 404: The user name does not exist + +The scheduled task will have the following type `MailboxesRestoreTask` +and the following `additionalInformation`: + +.... +{ + "type":"MailboxesRestoreTask", + "timestamp":"2007-12-03T10:15:30Z", + "username": "user" +} +.... + +Note: The account must be empty for the restore to succeed. If the user already has mailboxes, the task will fail. + === ReIndexing a user mails .... diff --git a/mailbox/backup/src/main/java/org/apache/james/mailbox/backup/DefaultMailboxBackup.java b/mailbox/backup/src/main/java/org/apache/james/mailbox/backup/DefaultMailboxBackup.java index bd45101443..e8c964270b 100644 --- a/mailbox/backup/src/main/java/org/apache/james/mailbox/backup/DefaultMailboxBackup.java +++ b/mailbox/backup/src/main/java/org/apache/james/mailbox/backup/DefaultMailboxBackup.java @@ -116,6 +116,7 @@ public class DefaultMailboxBackup implements MailboxBackup { public Publisher<BackupStatus> restore(Username username, InputStream source) { try { if (isAccountNonEmpty(username)) { + LOGGER.warn("Warning, account should be empty before performing a restoration for user: {}", username); return Mono.just(BackupStatus.NON_EMPTY_RECEIVER_ACCOUNT); } } catch (Exception e) { diff --git a/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java b/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java index ce9af6b50f..335c00faf8 100644 --- a/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java +++ b/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java @@ -86,7 +86,7 @@ import org.apache.james.modules.server.JmapUploadCleanupModule; import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; -import org.apache.james.modules.server.MailboxesExportRoutesModule; +import org.apache.james.modules.server.MailboxesBackupRoutesModule; import org.apache.james.modules.server.MessagesRoutesModule; import org.apache.james.modules.server.RabbitMailQueueRoutesModule; import org.apache.james.modules.server.SieveRoutesModule; @@ -119,7 +119,7 @@ public class CassandraRabbitMQJamesServerMain implements JamesServerMain { new JmapUploadCleanupModule(), new UserIdentityModule(), new JmapTasksModule(), - new MailboxesExportRoutesModule(), + new MailboxesBackupRoutesModule(), new MailboxRoutesModule(), new MailQueueRoutesModule(), new MailRepositoriesRoutesModule(), diff --git a/server/apps/distributed-pop3-app/src/main/java/org/apache/james/DistributedPOP3JamesServerMain.java b/server/apps/distributed-pop3-app/src/main/java/org/apache/james/DistributedPOP3JamesServerMain.java index 0b799ca7dd..1d3112d08c 100644 --- a/server/apps/distributed-pop3-app/src/main/java/org/apache/james/DistributedPOP3JamesServerMain.java +++ b/server/apps/distributed-pop3-app/src/main/java/org/apache/james/DistributedPOP3JamesServerMain.java @@ -86,7 +86,7 @@ import org.apache.james.modules.server.JMXServerModule; import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; -import org.apache.james.modules.server.MailboxesExportRoutesModule; +import org.apache.james.modules.server.MailboxesBackupRoutesModule; import org.apache.james.modules.server.MessagesRoutesModule; import org.apache.james.modules.server.RabbitMailQueueRoutesModule; import org.apache.james.modules.server.UserIdentityModule; @@ -114,7 +114,7 @@ public class DistributedPOP3JamesServerMain implements JamesServerMain { new VacationRoutesModule(), new InconsistencyQuotasSolvingRoutesModule(), new InconsistencySolvingRoutesModule(), - new MailboxesExportRoutesModule(), + new MailboxesBackupRoutesModule(), new MailboxRoutesModule(), new MailQueueRoutesModule(), new MailRepositoriesRoutesModule(), diff --git a/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java b/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java index 0ae1d22ad4..7c1b829ab4 100644 --- a/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java +++ b/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java @@ -57,7 +57,7 @@ import org.apache.james.modules.server.JmapTasksModule; import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; -import org.apache.james.modules.server.MailboxesExportRoutesModule; +import org.apache.james.modules.server.MailboxesBackupRoutesModule; import org.apache.james.modules.server.MailetContainerModule; import org.apache.james.modules.server.NoJwtModule; import org.apache.james.modules.server.RawPostDequeueDecoratorModule; @@ -86,7 +86,7 @@ public class MemoryJamesServerMain implements JamesServerMain { new DeletedMessageVaultRoutesModule(), new DLPRoutesModule(), new InconsistencyQuotasSolvingRoutesModule(), - new MailboxesExportRoutesModule(), + new MailboxesBackupRoutesModule(), new MailboxRoutesModule(), new MailQueueRoutesModule(), new MailRepositoriesRoutesModule(), diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 55254401e0..3095262e37 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -83,7 +83,7 @@ import org.apache.james.modules.server.JmapUploadCleanupModule; import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; -import org.apache.james.modules.server.MailboxesExportRoutesModule; +import org.apache.james.modules.server.MailboxesBackupRoutesModule; import org.apache.james.modules.server.RabbitMailQueueRoutesModule; import org.apache.james.modules.server.ReIndexingModule; import org.apache.james.modules.server.SieveRoutesModule; @@ -121,7 +121,7 @@ public class PostgresJamesServerMain implements JamesServerMain { new ReIndexingModule(), new SieveRoutesModule(), new WebAdminReIndexingTaskSerializationModule(), - new MailboxesExportRoutesModule(), + new MailboxesBackupRoutesModule(), new UserIdentityModule(), new DLPRoutesModule(), new JmapUploadCleanupModule(), diff --git a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java b/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesBackupRoutesModule.java similarity index 71% copy from server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java copy to server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesBackupRoutesModule.java index 2a7021f842..43898538c7 100644 --- a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java +++ b/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesBackupRoutesModule.java @@ -22,6 +22,8 @@ package org.apache.james.modules.server; import org.apache.james.webadmin.routes.UserMailboxesRoutes; import org.apache.james.webadmin.service.ExportService; import org.apache.james.webadmin.service.MailboxesExportRequestToTask; +import org.apache.james.webadmin.service.MailboxesRestoreRequestToTask; +import org.apache.james.webadmin.service.RestoreService; import org.apache.james.webadmin.tasks.TaskFromRequestRegistry; import com.google.inject.AbstractModule; @@ -29,15 +31,18 @@ import com.google.inject.Scopes; import com.google.inject.multibindings.Multibinder; import com.google.inject.name.Names; -public class MailboxesExportRoutesModule extends AbstractModule { +public class MailboxesBackupRoutesModule extends AbstractModule { @Override protected void configure() { install(new MailboxesBackupModule()); - install(new WebadminMailboxExportTaskSerializationModule()); + install(new WebadminMailboxBackupTaskSerializationModule()); bind(ExportService.class).in(Scopes.SINGLETON); - Multibinder.newSetBinder(binder(), TaskFromRequestRegistry.TaskRegistration.class, Names.named(UserMailboxesRoutes.USER_MAILBOXES_OPERATIONS_INJECTION_KEY)) - .addBinding().to(MailboxesExportRequestToTask.class); + bind(RestoreService.class).in(Scopes.SINGLETON); + + Multibinder<TaskFromRequestRegistry.TaskRegistration> taskRegistrations = Multibinder.newSetBinder(binder(), TaskFromRequestRegistry.TaskRegistration.class, Names.named(UserMailboxesRoutes.USER_MAILBOXES_OPERATIONS_INJECTION_KEY)); + taskRegistrations.addBinding().to(MailboxesExportRequestToTask.class); + taskRegistrations.addBinding().to(MailboxesRestoreRequestToTask.class); } } diff --git a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/WebadminMailboxExportTaskSerializationModule.java b/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/WebadminMailboxBackupTaskSerializationModule.java similarity index 70% rename from server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/WebadminMailboxExportTaskSerializationModule.java rename to server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/WebadminMailboxBackupTaskSerializationModule.java index 7afff0bfab..8d580c115c 100644 --- a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/WebadminMailboxExportTaskSerializationModule.java +++ b/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/WebadminMailboxBackupTaskSerializationModule.java @@ -18,6 +18,7 @@ ****************************************************************/ package org.apache.james.modules.server; +import org.apache.james.blob.api.BlobId; import org.apache.james.server.task.json.dto.AdditionalInformationDTO; import org.apache.james.server.task.json.dto.AdditionalInformationDTOModule; import org.apache.james.server.task.json.dto.TaskDTO; @@ -28,12 +29,15 @@ import org.apache.james.webadmin.dto.DTOModuleInjections; import org.apache.james.webadmin.service.ExportService; import org.apache.james.webadmin.service.MailboxesExportTask; import org.apache.james.webadmin.service.MailboxesExportTaskAdditionalInformationDTO; +import org.apache.james.webadmin.service.MailboxesRestoreTaskAdditionalInformationDTO; +import org.apache.james.webadmin.service.MailboxesRestoreTaskDTO; +import org.apache.james.webadmin.service.RestoreService; import com.google.inject.AbstractModule; import com.google.inject.multibindings.ProvidesIntoSet; import com.google.inject.name.Named; -public class WebadminMailboxExportTaskSerializationModule extends AbstractModule { +public class WebadminMailboxBackupTaskSerializationModule extends AbstractModule { @ProvidesIntoSet public TaskDTOModule<? extends Task, ? extends TaskDTO> mailboxesExportTask(ExportService exportService) { return MailboxesExportTask.module(exportService); @@ -49,4 +53,20 @@ public class WebadminMailboxExportTaskSerializationModule extends AbstractModule public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> webAdminMailboxesExportAdditionalInformation() { return MailboxesExportTaskAdditionalInformationDTO.SERIALIZATION_MODULE; } + + @ProvidesIntoSet + public TaskDTOModule<? extends Task, ? extends TaskDTO> mailboxesRestoreTask(RestoreService restoreService, BlobId.Factory blobIdFactory) { + return MailboxesRestoreTaskDTO.module(restoreService, blobIdFactory); + } + + @ProvidesIntoSet + public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> mailboxesRestoreAdditionalInformation() { + return MailboxesRestoreTaskAdditionalInformationDTO.SERIALIZATION_MODULE; + } + + @Named(DTOModuleInjections.WEBADMIN_DTO) + @ProvidesIntoSet + public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> webAdminMailboxesRestoreAdditionalInformation() { + return MailboxesRestoreTaskAdditionalInformationDTO.SERIALIZATION_MODULE; + } } diff --git a/server/protocols/webadmin-integration-test/webadmin-integration-test-common/src/main/java/org/apache/james/webadmin/integration/WebAdminServerIntegrationTest.java b/server/protocols/webadmin-integration-test/webadmin-integration-test-common/src/main/java/org/apache/james/webadmin/integration/WebAdminServerIntegrationTest.java index 2e1531470b..144db2c2dd 100644 --- a/server/protocols/webadmin-integration-test/webadmin-integration-test-common/src/main/java/org/apache/james/webadmin/integration/WebAdminServerIntegrationTest.java +++ b/server/protocols/webadmin-integration-test/webadmin-integration-test-common/src/main/java/org/apache/james/webadmin/integration/WebAdminServerIntegrationTest.java @@ -34,7 +34,9 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; +import java.io.ByteArrayOutputStream; import java.util.List; +import java.util.zip.ZipOutputStream; import org.apache.james.GuiceJamesServer; import org.apache.james.modules.MailboxProbeImpl; @@ -456,6 +458,32 @@ public abstract class WebAdminServerIntegrationTest { .body("type", is("MailboxesExportTask")); } + @Test + void mailboxesRestoreTasksShouldBeExposed() throws Exception { + dataProbe.addUser(USERNAME, "anyPassword"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // empty zip + } + byte[] emptyZip = baos.toByteArray(); + + String taskId = with() + .queryParam("task", "restore") + .body(emptyZip) + .post("/users/" + USERNAME + "/mailboxes") + .jsonPath() + .get("taskId"); + + given() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is("completed")) + .body("type", is("MailboxesRestoreTask")); + } + @Test void createMissParentsTasksShouldBeExposed() { String taskId = with() diff --git a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreRequestToTask.java b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreRequestToTask.java new file mode 100644 index 0000000000..0b95d3efe2 --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreRequestToTask.java @@ -0,0 +1,74 @@ +/**************************************************************** + * 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.service; + +import jakarta.inject.Inject; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.core.Username; +import org.apache.james.task.Task; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.webadmin.tasks.TaskFromRequestRegistry; +import org.apache.james.webadmin.tasks.TaskRegistrationKey; +import org.apache.james.webadmin.utils.ErrorResponder; +import org.eclipse.jetty.http.HttpStatus; + +import reactor.core.publisher.Mono; +import spark.Request; + +public class MailboxesRestoreRequestToTask extends TaskFromRequestRegistry.TaskRegistration { + + public static final TaskRegistrationKey TASK_REGISTRATION_KEY = TaskRegistrationKey.of("restore"); + + @Inject + MailboxesRestoreRequestToTask(RestoreService restoreService, UsersRepository usersRepository, BlobStore blobStore) { + super(TASK_REGISTRATION_KEY, + request -> toTask(restoreService, usersRepository, blobStore, request)); + } + + private static Task toTask(RestoreService restoreService, + UsersRepository usersRepository, + BlobStore blobStore, + Request request) throws UsersRepositoryException { + Username username = Username.of(request.params("username")); + if (!usersRepository.contains(username)) { + throw ErrorResponder.builder() + .type(ErrorResponder.ErrorType.NOT_FOUND) + .statusCode(HttpStatus.NOT_FOUND_404) + .message(String.format("User '%s' does not exist", username.asString())) + .haltError(); + } + + byte[] data = request.bodyAsBytes(); + if (data.length == 0) { + throw ErrorResponder.builder() + .type(ErrorResponder.ErrorType.INVALID_ARGUMENT) + .statusCode(HttpStatus.BAD_REQUEST_400) + .message("Request body must contain the ZIP backup data") + .haltError(); + } + + BlobId blobId = Mono.from(blobStore.save(blobStore.getDefaultBucketName(), data, BlobStore.StoragePolicy.LOW_COST)).block(); + + return new MailboxesRestoreTask(restoreService, username, blobId); + } +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTask.java b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTask.java new file mode 100644 index 0000000000..e7365e0b8d --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTask.java @@ -0,0 +1,88 @@ +/**************************************************************** + * 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.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.core.Username; +import org.apache.james.task.Task; +import org.apache.james.task.TaskExecutionDetails; +import org.apache.james.task.TaskType; + +public class MailboxesRestoreTask implements Task { + static final TaskType TASK_TYPE = TaskType.of("MailboxesRestoreTask"); + + public static class AdditionalInformation implements TaskExecutionDetails.AdditionalInformation { + private final Username username; + private final Instant timestamp; + + public AdditionalInformation(Username username, Instant timestamp) { + this.username = username; + this.timestamp = timestamp; + } + + public String getUsername() { + return username.asString(); + } + + @Override + public Instant timestamp() { + return timestamp; + } + } + + private final Username username; + private final RestoreService service; + private final BlobId blobId; + + MailboxesRestoreTask(RestoreService service, Username username, BlobId blobId) { + this.username = username; + this.service = service; + this.blobId = blobId; + } + + @Override + public Result run() { + return service.restore(username, blobId) + .block(); + } + + @Override + public TaskType type() { + return TASK_TYPE; + } + + public Username getUsername() { + return username; + } + + public BlobId getBlobId() { + return blobId; + } + + @Override + public Optional<TaskExecutionDetails.AdditionalInformation> details() { + return Optional.of(new AdditionalInformation(username, + Clock.systemUTC().instant())); + } +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTaskAdditionalInformationDTO.java b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTaskAdditionalInformationDTO.java new file mode 100644 index 0000000000..0a0e8e6bf0 --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTaskAdditionalInformationDTO.java @@ -0,0 +1,71 @@ +/**************************************************************** + * 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.service; + +import java.time.Instant; + +import org.apache.james.core.Username; +import org.apache.james.json.DTOModule; +import org.apache.james.server.task.json.dto.AdditionalInformationDTO; +import org.apache.james.server.task.json.dto.AdditionalInformationDTOModule; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MailboxesRestoreTaskAdditionalInformationDTO implements AdditionalInformationDTO { + + public static final AdditionalInformationDTOModule<MailboxesRestoreTask.AdditionalInformation, MailboxesRestoreTaskAdditionalInformationDTO> SERIALIZATION_MODULE = + DTOModule.forDomainObject(MailboxesRestoreTask.AdditionalInformation.class) + .convertToDTO(MailboxesRestoreTaskAdditionalInformationDTO.class) + .toDomainObjectConverter(dto -> new MailboxesRestoreTask.AdditionalInformation( + Username.of(dto.username), + dto.timestamp)) + .toDTOConverter((details, type) -> new MailboxesRestoreTaskAdditionalInformationDTO( + type, + details.timestamp(), + details.getUsername())) + .typeName(MailboxesRestoreTask.TASK_TYPE.asString()) + .withFactory(AdditionalInformationDTOModule::new); + + private final String username; + private final Instant timestamp; + private final String type; + + private MailboxesRestoreTaskAdditionalInformationDTO(@JsonProperty("type") String type, + @JsonProperty("timestamp") Instant timestamp, + @JsonProperty("username") String username) { + this.type = type; + this.timestamp = timestamp; + this.username = username; + } + + @Override + public String getType() { + return type; + } + + @Override + public Instant getTimestamp() { + return timestamp; + } + + public String getUsername() { + return username; + } +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTaskDTO.java b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTaskDTO.java new file mode 100644 index 0000000000..1307e53250 --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/MailboxesRestoreTaskDTO.java @@ -0,0 +1,75 @@ +/**************************************************************** + * 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.service; + +import static org.apache.james.webadmin.service.MailboxesRestoreTask.TASK_TYPE; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.core.Username; +import org.apache.james.json.DTOModule; +import org.apache.james.server.task.json.dto.TaskDTO; +import org.apache.james.server.task.json.dto.TaskDTOModule; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MailboxesRestoreTaskDTO implements TaskDTO { + private final String type; + private final String username; + private final String blobId; + + public MailboxesRestoreTaskDTO(@JsonProperty("type") String type, + @JsonProperty("username") String username, + @JsonProperty("blobId") String blobId) { + this.type = type; + this.username = username; + this.blobId = blobId; + } + + @Override + public String getType() { + return type; + } + + public String getUsername() { + return username; + } + + public String getBlobId() { + return blobId; + } + + public static TaskDTOModule<MailboxesRestoreTask, MailboxesRestoreTaskDTO> module(RestoreService service, BlobId.Factory blobIdFactory) { + return DTOModule + .forDomainObject(MailboxesRestoreTask.class) + .convertToDTO(MailboxesRestoreTaskDTO.class) + .toDomainObjectConverter(dto -> dto.fromDTO(service, blobIdFactory)) + .toDTOConverter(MailboxesRestoreTaskDTO::toDTO) + .typeName(TASK_TYPE.asString()) + .withFactory(TaskDTOModule::new); + } + + public MailboxesRestoreTask fromDTO(RestoreService service, BlobId.Factory blobIdFactory) { + return new MailboxesRestoreTask(service, Username.of(username), blobIdFactory.of(blobId)); + } + + public static MailboxesRestoreTaskDTO toDTO(MailboxesRestoreTask domainObject, String typeName) { + return new MailboxesRestoreTaskDTO(typeName, domainObject.getUsername().asString(), domainObject.getBlobId().asString()); + } +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/RestoreService.java b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/RestoreService.java new file mode 100644 index 0000000000..54c47188bf --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/RestoreService.java @@ -0,0 +1,79 @@ +/**************************************************************** + * 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.service; + +import java.io.InputStream; + +import jakarta.inject.Inject; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.core.Username; +import org.apache.james.mailbox.backup.MailboxBackup; +import org.apache.james.mailbox.backup.MailboxBackup.BackupStatus; +import org.apache.james.task.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import reactor.core.publisher.Mono; + +public class RestoreService { + private static final Logger LOGGER = LoggerFactory.getLogger(RestoreService.class); + + private final MailboxBackup mailboxBackup; + private final BlobStore blobStore; + + @Inject + public RestoreService(MailboxBackup mailboxBackup, BlobStore blobstore) { + this.mailboxBackup = mailboxBackup; + this.blobStore = blobstore; + } + + public Mono<Task.Result> restore(Username username, BlobId blobId) { + try (InputStream inputStream = blobStore.read(blobStore.getDefaultBucketName(), blobId)) { + return restore(username, inputStream); + } catch (Exception e) { + LOGGER.error("Error restoring mailboxes for user {}", username.asString(), e); + return Mono.just(Task.Result.PARTIAL); + } finally { + try { + Mono.from(blobStore.delete(blobStore.getDefaultBucketName(), blobId)).block(); + } catch (Exception e) { + LOGGER.error("Error deleting blob {} after restore", blobId.asString(), e); + } + } + } + + private Mono<Task.Result> restore(Username username, InputStream source) { + try { + return Mono.from(mailboxBackup.restore(username, source)) + .map(this::computeTaskResult); + } catch (Exception e) { + return Mono.error(e); + } + } + + private Task.Result computeTaskResult(BackupStatus status) { + if (status == BackupStatus.DONE) { + return Task.Result.COMPLETED; + } + return Task.Result.PARTIAL; + } +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreRequestToTaskTest.java b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreRequestToTaskTest.java new file mode 100644 index 0000000000..72ba4ef92f --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreRequestToTaskTest.java @@ -0,0 +1,240 @@ +/**************************************************************** + * 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.service; + +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.apache.james.webadmin.service.ExportServiceTestSystem.BOB; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +import java.io.ByteArrayOutputStream; +import java.util.zip.ZipOutputStream; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.PlainBlobId; +import org.apache.james.blob.export.file.FileSystemExtension; +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.json.DTOConverter; +import org.apache.james.task.Hostname; +import org.apache.james.task.MemoryTaskManager; +import org.apache.james.task.TaskManager; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.webadmin.Routes; +import org.apache.james.webadmin.WebAdminServer; +import org.apache.james.webadmin.WebAdminUtils; +import org.apache.james.webadmin.routes.TasksRoutes; +import org.apache.james.webadmin.tasks.TaskFromRequestRegistry; +import org.apache.james.webadmin.utils.ErrorResponder; +import org.apache.james.webadmin.utils.JsonTransformer; +import org.eclipse.jetty.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.restassured.RestAssured; +import spark.Service; + +@ExtendWith(FileSystemExtension.class) +class MailboxesRestoreRequestToTaskTest { + + private final class RestoreRoutes implements Routes { + private final RestoreService restoreService; + private final TaskManager taskManager; + private final UsersRepository usersRepository; + private final BlobStore blobStore; + + private RestoreRoutes(RestoreService restoreService, TaskManager taskManager, UsersRepository usersRepository, BlobStore blobStore) { + this.restoreService = restoreService; + this.taskManager = taskManager; + this.usersRepository = usersRepository; + this.blobStore = blobStore; + } + + @Override + public String getBasePath() { + return BASE_PATH; + } + + @Override + public void define(Service service) { + service.post(BASE_PATH, + TaskFromRequestRegistry.builder() + .parameterName("task") + .registrations(new MailboxesRestoreRequestToTask(this.restoreService, usersRepository, blobStore)) + .buildAsRoute(taskManager), + new JsonTransformer()); + } + } + + private static final String BASE_PATH = "users/:username/mailboxes"; + private static final BlobId.Factory BLOB_ID_FACTORY = new PlainBlobId.Factory(); + + private WebAdminServer webAdminServer; + private MemoryTaskManager taskManager; + private ExportServiceTestSystem testSystem; + + @BeforeEach + void setUp(FileSystem fileSystem) throws Exception { + testSystem = new ExportServiceTestSystem(fileSystem); + taskManager = new MemoryTaskManager(new Hostname("foo")); + + JsonTransformer jsonTransformer = new JsonTransformer(); + webAdminServer = WebAdminUtils.createWebAdminServer( + new TasksRoutes(taskManager, jsonTransformer, + DTOConverter.of(MailboxesRestoreTaskAdditionalInformationDTO.SERIALIZATION_MODULE)), + new RestoreRoutes( + new RestoreService(testSystem.backup, testSystem.blobStore), taskManager, testSystem.usersRepository, testSystem.blobStore)) + .start(); + + RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer) + .setBasePath("users/" + BOB.asString() + "/mailboxes") + .build(); + } + + @AfterEach + void afterEach() { + webAdminServer.destroy(); + taskManager.stop(); + } + + private byte[] emptyZip() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // empty zip + } + return baos.toByteArray(); + } + + @Test + void taskRequestParameterShouldBeCompulsory() { + when() + .post() + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid arguments supplied in the user request")) + .body("details", is("'task' query parameter is compulsory. Supported values are [restore]")); + } + + @Test + void restoreMailboxesShouldFailUponEmptyTask() { + given() + .queryParam("task", "") + .post() + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid arguments supplied in the user request")) + .body("details", is("'task' query parameter cannot be empty or blank. Supported values are [restore]")); + } + + @Test + void restoreMailboxesShouldFailUponInvalidTask() { + given() + .queryParam("task", "invalid") + .post() + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid arguments supplied in the user request")) + .body("details", is("Invalid value supplied for query parameter 'task': invalid. Supported values are [restore]")); + } + + @Test + void restoreMailboxesShouldFailUponBadUsername() throws Exception { + given() + .basePath("users/bad@bad@bad/mailboxes") + .queryParam("task", "restore") + .body(emptyZip()) + .post() + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Invalid arguments supplied in the user request")) + .body("details", is("Domain parts ASCII chars must be a-z A-Z 0-9 - or _ in bad@bad")); + } + + @Test + void restoreMailboxesShouldFailUponUnknownUser() throws Exception { + given() + .basePath("users/notFound/mailboxes") + .queryParam("task", "restore") + .body(emptyZip()) + .post() + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body("statusCode", is(404)) + .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType())) + .body("message", is("User 'notfound' does not exist")); + } + + @Test + void restoreMailboxesShouldFailUponEmptyBody() { + given() + .queryParam("task", "restore") + .post() + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body("statusCode", is(400)) + .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType())) + .body("message", is("Request body must contain the ZIP backup data")); + } + + @Test + void postShouldCreateANewTask() throws Exception { + given() + .queryParam("task", "restore") + .body(emptyZip()) + .post() + .then() + .statusCode(HttpStatus.CREATED_201) + .body("taskId", is(notNullValue())); + } + + @Test + void restoreMailboxesShouldCompleteWhenUserHasNoMailbox() throws Exception { + String taskId = given() + .queryParam("task", "restore") + .body(emptyZip()) + .post() + .jsonPath() + .get("taskId"); + + given() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is("completed")) + .body("taskId", is(taskId)) + .body("type", is("MailboxesRestoreTask")) + .body("additionalInformation.username", is(BOB.asString())) + .body("startedDate", is(notNullValue())) + .body("submitDate", is(notNullValue())) + .body("completedDate", is(notNullValue())); + } +} diff --git a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreTaskAdditionalInformationDTOTest.java similarity index 55% copy from server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java copy to server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreTaskAdditionalInformationDTOTest.java index 2a7021f842..633e6abc74 100644 --- a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java +++ b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreTaskAdditionalInformationDTOTest.java @@ -17,27 +17,25 @@ * under the License. * ****************************************************************/ -package org.apache.james.modules.server; +package org.apache.james.webadmin.service; -import org.apache.james.webadmin.routes.UserMailboxesRoutes; -import org.apache.james.webadmin.service.ExportService; -import org.apache.james.webadmin.service.MailboxesExportRequestToTask; -import org.apache.james.webadmin.tasks.TaskFromRequestRegistry; +import java.time.Instant; -import com.google.inject.AbstractModule; -import com.google.inject.Scopes; -import com.google.inject.multibindings.Multibinder; -import com.google.inject.name.Names; +import org.apache.james.JsonSerializationVerifier; +import org.apache.james.core.Username; +import org.apache.james.util.ClassLoaderUtils; +import org.junit.jupiter.api.Test; -public class MailboxesExportRoutesModule extends AbstractModule { +class MailboxesRestoreTaskAdditionalInformationDTOTest { + private static final Instant INSTANT = Instant.parse("2007-12-03T10:15:30.00Z"); + private static final MailboxesRestoreTask.AdditionalInformation DOMAIN_OBJECT = new MailboxesRestoreTask.AdditionalInformation( + Username.of("bob"), INSTANT); - @Override - protected void configure() { - install(new MailboxesBackupModule()); - install(new WebadminMailboxExportTaskSerializationModule()); - - bind(ExportService.class).in(Scopes.SINGLETON); - Multibinder.newSetBinder(binder(), TaskFromRequestRegistry.TaskRegistration.class, Names.named(UserMailboxesRoutes.USER_MAILBOXES_OPERATIONS_INJECTION_KEY)) - .addBinding().to(MailboxesExportRequestToTask.class); + @Test + void shouldMatchJsonSerializationContract() throws Exception { + JsonSerializationVerifier.dtoModule(MailboxesRestoreTaskAdditionalInformationDTO.SERIALIZATION_MODULE) + .bean(DOMAIN_OBJECT) + .json(ClassLoaderUtils.getSystemResourceAsString("json/mailboxesRestore.additionalInformation.json")) + .verify(); } } diff --git a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreTaskSerializationTest.java similarity index 53% rename from server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java rename to server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreTaskSerializationTest.java index 2a7021f842..1391ff0abf 100644 --- a/server/container/guice/protocols/webadmin-mailbox/src/main/java/org/apache/james/modules/server/MailboxesExportRoutesModule.java +++ b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/MailboxesRestoreTaskSerializationTest.java @@ -17,27 +17,33 @@ * under the License. * ****************************************************************/ -package org.apache.james.modules.server; +package org.apache.james.webadmin.service; -import org.apache.james.webadmin.routes.UserMailboxesRoutes; -import org.apache.james.webadmin.service.ExportService; -import org.apache.james.webadmin.service.MailboxesExportRequestToTask; -import org.apache.james.webadmin.tasks.TaskFromRequestRegistry; +import static org.mockito.Mockito.mock; -import com.google.inject.AbstractModule; -import com.google.inject.Scopes; -import com.google.inject.multibindings.Multibinder; -import com.google.inject.name.Names; +import org.apache.james.JsonSerializationVerifier; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.PlainBlobId; +import org.apache.james.core.Username; +import org.apache.james.util.ClassLoaderUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -public class MailboxesExportRoutesModule extends AbstractModule { +class MailboxesRestoreTaskSerializationTest { + RestoreService service; + BlobId.Factory blobIdFactory; - @Override - protected void configure() { - install(new MailboxesBackupModule()); - install(new WebadminMailboxExportTaskSerializationModule()); + @BeforeEach + void setUp() { + service = mock(RestoreService.class); + blobIdFactory = new PlainBlobId.Factory(); + } - bind(ExportService.class).in(Scopes.SINGLETON); - Multibinder.newSetBinder(binder(), TaskFromRequestRegistry.TaskRegistration.class, Names.named(UserMailboxesRoutes.USER_MAILBOXES_OPERATIONS_INJECTION_KEY)) - .addBinding().to(MailboxesExportRequestToTask.class); + @Test + void shouldMatchJsonSerializationContract() throws Exception { + JsonSerializationVerifier.dtoModule(MailboxesRestoreTaskDTO.module(service, blobIdFactory)) + .bean(new MailboxesRestoreTask(service, Username.of("bob"), blobIdFactory.of("abc123"))) + .json(ClassLoaderUtils.getSystemResourceAsString("json/mailboxesRestore.task.json")) + .verify(); } } diff --git a/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/RestoreServiceTest.java b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/RestoreServiceTest.java new file mode 100644 index 0000000000..0ddc394ccb --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/test/java/org/apache/james/webadmin/service/RestoreServiceTest.java @@ -0,0 +1,195 @@ +/**************************************************************** + * 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.service; + +import static org.apache.james.webadmin.service.ExportServiceTestSystem.BOB; +import static org.apache.james.webadmin.service.ExportServiceTestSystem.CEDRIC; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.ObjectNotFoundException; +import org.apache.james.blob.export.file.FileSystemExtension; +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.ComposedMessageId; +import org.apache.james.mailbox.model.FetchGroup; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.MessageResultIterator; +import org.apache.james.task.Task; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +import com.github.fge.lambdas.Throwing; + +import reactor.core.publisher.Mono; + +@ExtendWith(FileSystemExtension.class) +class RestoreServiceTest { + private static final int BUFFER_SIZE = 4096; + private static final String MESSAGE_CONTENT = "MIME-Version: 1.0\r\n" + + "Subject: test\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "\r\n" + + "testmail"; + + private RestoreService testee; + private ExportServiceTestSystem testSystem; + + @BeforeEach + void setUp(FileSystem fileSystem) throws Exception { + testSystem = new ExportServiceTestSystem(fileSystem); + testee = Mockito.spy(new RestoreService(testSystem.backup, testSystem.blobStore)); + } + + @Test + void restoreShouldReturnCompleteWhenExistingUserWithoutDataAndEmptyZip() throws Exception { + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + assertThat(testee.restore(CEDRIC, blobId).block()) + .isEqualTo(Task.Result.COMPLETED); + } + + @Test + void restoreShouldReturnCompleteWhenExistingUserWithoutDataAndNonEmptyZip() throws Exception { + createAMailboxWithAMail(MESSAGE_CONTENT); + + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + assertThat(testee.restore(CEDRIC, blobId).block()) + .isEqualTo(Task.Result.COMPLETED); + } + + @Test + void restoreShouldReturnPartialWhenNonEmptyAccount() throws Exception { + createAMailboxWithAMail(MESSAGE_CONTENT); + + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + assertThat(testee.restore(BOB, blobId).block()) + .isEqualTo(Task.Result.PARTIAL); + } + + @Test + void restoreShouldReturnPartialWhenFailed() throws Exception { + doThrow(new RuntimeException()) + .when(testSystem.blobStore) + .read(any(), any()); + + createAMailboxWithAMail(MESSAGE_CONTENT); + + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + assertThat(testee.restore(CEDRIC, blobId).block()) + .isEqualTo(Task.Result.PARTIAL); + } + + @Test + void restoreShouldNoopWhenEmptyZip() throws Exception { + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + testee.restore(CEDRIC, blobId).block(); + + MailboxSession cedricSession = testSystem.mailboxManager.createSystemSession(CEDRIC); + assertThat(testSystem.mailboxManager.list(cedricSession)) + .isEmpty(); + } + + @Test + void restoreShouldRestoreContentFromNonEmptyZip() throws Exception { + createAMailboxWithAMail(MESSAGE_CONTENT); + + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + testee.restore(CEDRIC, blobId).block(); + + MailboxSession cedricSession = testSystem.mailboxManager.createSystemSession(CEDRIC); + MessageManager mailbox = testSystem.mailboxManager.getMailbox(MailboxPath.inbox(CEDRIC), cedricSession); + MessageResultIterator resultIterator = mailbox.getMessages(MessageRange.all(), FetchGroup.FULL_CONTENT, cedricSession); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(resultIterator).toIterable().hasSize(1); + softly.assertThat(Throwing.supplier(() -> resultIterator.next().getBody().asBytesSequence())).isEqualTo(MESSAGE_CONTENT.getBytes(StandardCharsets.UTF_8)); + }); + } + + @Test + void restoreShouldDeleteBlobAfterCompletion() throws Exception { + createAMailboxWithAMail(MESSAGE_CONTENT); + + ByteArrayOutputStream destination = new ByteArrayOutputStream(BUFFER_SIZE); + testSystem.backup.backupAccount(BOB, destination); + + InputStream source = new ByteArrayInputStream(destination.toByteArray()); + BlobId blobId = Mono.from(testSystem.blobStore.save(testSystem.blobStore.getDefaultBucketName(), source, BlobStore.StoragePolicy.LOW_COST)).block(); + + testee.restore(CEDRIC, blobId).block(); + + assertThatThrownBy(() -> testSystem.blobStore.read(testSystem.blobStore.getDefaultBucketName(), blobId)) + .isInstanceOf(ObjectNotFoundException.class); + } + + private ComposedMessageId createAMailboxWithAMail(String message) throws MailboxException { + MailboxPath bobInboxPath = MailboxPath.inbox(BOB); + testSystem.mailboxManager.createMailbox(bobInboxPath, testSystem.bobSession); + return testSystem.mailboxManager.getMailbox(bobInboxPath, testSystem.bobSession) + .appendMessage(MessageManager.AppendCommand.builder() + .build(message), + testSystem.bobSession) + .getId(); + } +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/test/resources/json/mailboxesRestore.additionalInformation.json b/server/protocols/webadmin/webadmin-mailbox/src/test/resources/json/mailboxesRestore.additionalInformation.json new file mode 100644 index 0000000000..498cf44057 --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/test/resources/json/mailboxesRestore.additionalInformation.json @@ -0,0 +1,5 @@ +{ + "type":"MailboxesRestoreTask", + "timestamp":"2007-12-03T10:15:30Z", + "username": "bob" +} diff --git a/server/protocols/webadmin/webadmin-mailbox/src/test/resources/json/mailboxesRestore.task.json b/server/protocols/webadmin/webadmin-mailbox/src/test/resources/json/mailboxesRestore.task.json new file mode 100644 index 0000000000..c19fc26185 --- /dev/null +++ b/server/protocols/webadmin/webadmin-mailbox/src/test/resources/json/mailboxesRestore.task.json @@ -0,0 +1,5 @@ +{ + "type":"MailboxesRestoreTask", + "username": "bob", + "blobId": "abc123" +} diff --git a/src/site/markdown/server/manage-webadmin.md b/src/site/markdown/server/manage-webadmin.md index 73a61639e7..3431b47717 100644 --- a/src/site/markdown/server/manage-webadmin.md +++ b/src/site/markdown/server/manage-webadmin.md @@ -1665,7 +1665,7 @@ Response codes: ### Exporting user mailboxes ``` -curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?action=export +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=export ``` Resource name `usernameToBeUsed` should be an existing user @@ -1686,6 +1686,32 @@ The scheduled task will have the following type `MailboxesExportTask` and the fo } ``` +### Restoring user mailboxes + +``` +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=restore --data-binary @backup.zip +``` + +Resource name `usernameToBeUsed` should be an existing user. The request body must contain the ZIP backup data. + +Response codes: + + - 201: Success. Corresponding task id is returned + - 400: The request body is empty + - 404: The user name does not exist + +The scheduled task will have the following type `MailboxesRestoreTask` and the following `additionalInformation`: + +``` +{ + "type":"MailboxesRestoreTask", + "timestamp":"2007-12-03T10:15:30Z", + "username": "user" +} +``` + +Note: The account must be empty for the restore to succeed. If the user already has mailboxes, the task will fail. + ### ReIndexing a user mails ``` --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
