You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2023/05/25 05:45:20 UTC

[james-project] branch master updated (d7e717632e -> 375aebbd21)

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

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


    from d7e717632e JAMES-3909 Mailboxes deletion step (#1571)
     new 79092f2142 JAMES-3909 Task + Webadmin route for delete all users data of a domain
     new 1baee47628 JAMES-3909 Add UsersRepository::listUsersOfADomainReactive
     new 375aebbd21 JAMES-3909 Add listing failedUsers to task additional information

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../docs/modules/ROOT/pages/operate/webadmin.adoc  |  30 ++++
 .../james/modules/server/DataRoutesModules.java    |  20 +++
 .../org/apache/james/user/api/UsersRepository.java |  10 ++
 .../james/user/lib/UsersRepositoryContract.java    |  16 ++
 .../james/webadmin/routes/DomainsRoutes.java       |  33 +++-
 .../webadmin/service/DeleteUserDataService.java    |   4 +
 .../service/DeleteUsersDataOfDomainTask.java       | 196 ++++++++++++++++++++
 ...rsDataOfDomainTaskAdditionalInformationDTO.java | 103 +++++++++++
 .../service/DeleteUsersDataOfDomainTaskDTO.java}   |  42 +++--
 .../james/webadmin/routes/DomainsRoutesTest.java   | 177 +++++++++++++++++-
 ...eteUsersDataOfDomainTaskSerializationTest.java} |  82 +++------
 .../service/DeleteUsersDataOfDomainTaskTest.java   | 199 +++++++++++++++++++++
 src/site/markdown/server/manage-webadmin.md        |  31 ++++
 13 files changed, 865 insertions(+), 78 deletions(-)
 create mode 100644 server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java
 create mode 100644 server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java
 copy server/protocols/webadmin/{webadmin-mailbox/src/main/java/org/apache/james/webadmin/service/EventDeadLettersRedeliverAllTaskDTO.java => webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java} (54%)
 copy server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/{DeleteUserDataTaskSerializationTest.java => DeleteUsersDataOfDomainTaskSerializationTest.java} (53%)
 create mode 100644 server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java


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


[james-project] 01/03: JAMES-3909 Task + Webadmin route for delete all users data of a domain

Posted by bt...@apache.org.
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 79092f21427011722b656fca5c58b5044235424f
Author: Quan Tran <hq...@linagora.com>
AuthorDate: Fri May 19 17:18:51 2023 +0700

    JAMES-3909 Task + Webadmin route for delete all users data of a domain
---
 .../docs/modules/ROOT/pages/operate/webadmin.adoc  |  27 +++
 .../james/modules/server/DataRoutesModules.java    |  20 +++
 .../james/webadmin/routes/DomainsRoutes.java       |  33 +++-
 .../webadmin/service/DeleteUserDataService.java    |   4 +
 .../service/DeleteUsersDataOfDomainTask.java       | 173 ++++++++++++++++++
 ...rsDataOfDomainTaskAdditionalInformationDTO.java |  81 +++++++++
 .../service/DeleteUsersDataOfDomainTaskDTO.java    |  68 ++++++++
 .../james/webadmin/routes/DomainsRoutesTest.java   | 174 +++++++++++++++++-
 ...leteUsersDataOfDomainTaskSerializationTest.java |  93 ++++++++++
 .../service/DeleteUsersDataOfDomainTaskTest.java   | 194 +++++++++++++++++++++
 src/site/markdown/server/manage-webadmin.md        |  28 +++
 11 files changed, 892 insertions(+), 3 deletions(-)

diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc
index 1a343c7d30..0a69f5618f 100644
--- a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc
+++ b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc
@@ -455,6 +455,33 @@ syntax
 * 400: source, domain and destination domain are the same
 * 404: `source.domain.tld` are not part of handled domains.
 
+=== Delete all users data of a domain
+
+....
+curl -XPOST http://ip:port/domains/{domainToBeUsed}?action=deleteData
+....
+
+Would create a task that deletes data of all users of the domain.
+
+[More details about endpoints returning a task](#_endpoints_returning_a_task).
+
+Response codes:
+
+* 201: Success. Corresponding task id is returned.
+* 400: Error in the request. Details can be found in the reported error.
+
+The scheduled task will have the following type `DeleteUsersDataOfDomainTask` and the following `additionalInformation`:
+
+....
+{
+        "type": "DeleteUsersDataOfDomainTask",
+        "domain": "domain.tld",
+        "successfulUsersCount": 2,
+        "failedUsersCount": 1,
+        "timestamp": "2023-05-22T08:52:47.076261Z"
+}
+....
+
 == Administrating users
 
 === Create a user
diff --git a/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java b/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java
index 5b3addf079..48fcf5efe2 100644
--- a/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java
+++ b/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DataRoutesModules.java
@@ -25,6 +25,7 @@ import org.apache.james.server.task.json.dto.TaskDTO;
 import org.apache.james.server.task.json.dto.TaskDTOModule;
 import org.apache.james.task.Task;
 import org.apache.james.task.TaskExecutionDetails;
+import org.apache.james.user.api.UsersRepository;
 import org.apache.james.webadmin.Routes;
 import org.apache.james.webadmin.dto.DTOModuleInjections;
 import org.apache.james.webadmin.dto.MappingSourceModule;
@@ -44,6 +45,8 @@ import org.apache.james.webadmin.routes.UsernameChangeRoutes;
 import org.apache.james.webadmin.service.DeleteUserDataService;
 import org.apache.james.webadmin.service.DeleteUserDataTaskAdditionalInformationDTO;
 import org.apache.james.webadmin.service.DeleteUserDataTaskDTO;
+import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTaskAdditionalInformationDTO;
+import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTaskDTO;
 import org.apache.james.webadmin.service.UsernameChangeService;
 import org.apache.james.webadmin.service.UsernameChangeTaskAdditionalInformationDTO;
 import org.apache.james.webadmin.service.UsernameChangeTaskDTO;
@@ -108,4 +111,21 @@ public class DataRoutesModules extends AbstractModule {
     public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> webAdminDeleteUserDataTaskAdditionalInformationDTO() {
         return DeleteUserDataTaskAdditionalInformationDTO.module();
     }
+
+    // delete all users data of a domain DTO modules
+    @ProvidesIntoSet
+    public TaskDTOModule<? extends Task, ? extends TaskDTO> deleteUsersDataOfDomainTaskDTO(DeleteUserDataService service, UsersRepository usersRepository) {
+        return DeleteUsersDataOfDomainTaskDTO.module(service, usersRepository);
+    }
+
+    @ProvidesIntoSet
+    public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> deleteUsersDataOfDomainTaskAdditionalInformationDTO() {
+        return DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module();
+    }
+
+    @Named(DTOModuleInjections.WEBADMIN_DTO)
+    @ProvidesIntoSet
+    public AdditionalInformationDTOModule<? extends TaskExecutionDetails.AdditionalInformation, ? extends AdditionalInformationDTO> webAdminDeleteUsersDataOfDomainTaskAdditionalInformationDTO() {
+        return DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module();
+    }
 }
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java
index 59ae633c61..34ef2f9ab9 100644
--- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DomainsRoutes.java
@@ -34,9 +34,15 @@ import org.apache.james.domainlist.api.DomainList;
 import org.apache.james.domainlist.api.DomainListException;
 import org.apache.james.rrt.api.RecipientRewriteTableException;
 import org.apache.james.rrt.api.SameSourceAndDestinationException;
+import org.apache.james.task.TaskManager;
+import org.apache.james.user.api.UsersRepository;
 import org.apache.james.webadmin.Routes;
 import org.apache.james.webadmin.dto.DomainAliasResponse;
+import org.apache.james.webadmin.service.DeleteUserDataService;
+import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTask;
 import org.apache.james.webadmin.service.DomainAliasService;
+import org.apache.james.webadmin.tasks.TaskFromRequestRegistry;
+import org.apache.james.webadmin.tasks.TaskRegistrationKey;
 import org.apache.james.webadmin.utils.ErrorResponder;
 import org.apache.james.webadmin.utils.ErrorResponder.ErrorType;
 import org.apache.james.webadmin.utils.JsonTransformer;
@@ -45,11 +51,13 @@ import org.eclipse.jetty.http.HttpStatus;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableSet;
 
 import spark.HaltException;
 import spark.Request;
 import spark.Response;
+import spark.Route;
 import spark.Service;
 
 public class DomainsRoutes implements Routes {
@@ -62,18 +70,26 @@ public class DomainsRoutes implements Routes {
     private static final String SPECIFIC_DOMAIN = DOMAINS + SEPARATOR + DOMAIN_NAME;
     private static final String ALIASES = "aliases";
     private static final String DOMAIN_ALIASES = SPECIFIC_DOMAIN + SEPARATOR + ALIASES;
+    private static final String DELETE_ALL_USERS_DATA_OF_A_DOMAIN_PATH = "/domains/:domainName";
     private static final String SPECIFIC_ALIAS = DOMAINS + SEPARATOR + DESTINATION_DOMAIN + SEPARATOR + ALIASES + SEPARATOR + SOURCE_DOMAIN;
+    private static final TaskRegistrationKey DELETE_USERS_DATA = TaskRegistrationKey.of("deleteData");
 
     private final DomainList domainList;
     private final DomainAliasService domainAliasService;
     private final JsonTransformer jsonTransformer;
+    private final DeleteUserDataService deleteUserDataService;
+    private final UsersRepository usersRepository;
+    private final TaskManager taskManager;
     private Service service;
 
     @Inject
-    DomainsRoutes(DomainList domainList, DomainAliasService domainAliasService, JsonTransformer jsonTransformer) {
+    DomainsRoutes(DomainList domainList, DomainAliasService domainAliasService, JsonTransformer jsonTransformer, DeleteUserDataService deleteUserDataService, UsersRepository usersRepository, TaskManager taskManager) {
         this.domainList = domainList;
         this.domainAliasService = domainAliasService;
         this.jsonTransformer = jsonTransformer;
+        this.deleteUserDataService = deleteUserDataService;
+        this.usersRepository = usersRepository;
+        this.taskManager = taskManager;
     }
 
     @Override
@@ -95,6 +111,21 @@ public class DomainsRoutes implements Routes {
         defineListAliases(service);
         defineAddAlias(service);
         defineRemoveAlias(service);
+
+        // delete data of all users of a domain
+        service.post(DELETE_ALL_USERS_DATA_OF_A_DOMAIN_PATH, deleteAllUsersData(), jsonTransformer);
+    }
+
+    public Route deleteAllUsersData() {
+        return TaskFromRequestRegistry.builder()
+            .parameterName("action")
+            .register(DELETE_USERS_DATA, request -> {
+                Domain domain = checkValidDomain(request.params(DOMAIN_NAME));
+                Preconditions.checkArgument(domainList.containsDomain(domain), "'domainName' parameter should be an existing domain");
+
+                return new DeleteUsersDataOfDomainTask(deleteUserDataService, domain, usersRepository);
+            })
+            .buildAsRoute(taskManager);
     }
 
     public void defineDeleteDomain() {
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java
index 2bd572b68c..e24987358d 100644
--- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUserDataService.java
@@ -143,4 +143,8 @@ public class DeleteUserDataService {
     public Performer performer(Optional<StepName> fromStep) {
         return new Performer(steps, new DeleteUserDataStatus(steps), fromStep);
     }
+
+    public Performer performer() {
+        return new Performer(steps, new DeleteUserDataStatus(steps), Optional.empty());
+    }
 }
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java
new file mode 100644
index 0000000000..84487aade6
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java
@@ -0,0 +1,173 @@
+/****************************************************************
+ * 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.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+
+import org.apache.james.core.Domain;
+import org.apache.james.core.Username;
+import org.apache.james.task.Task;
+import org.apache.james.task.TaskExecutionDetails;
+import org.apache.james.task.TaskType;
+import org.apache.james.user.api.UsersRepository;
+import org.reactivestreams.Publisher;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public class DeleteUsersDataOfDomainTask implements Task {
+    static final TaskType TYPE = TaskType.of("DeleteUsersDataOfDomainTask");
+    private static final int LOW_CONCURRENCY = 2;
+
+    public static class AdditionalInformation implements TaskExecutionDetails.AdditionalInformation {
+        private final Instant timestamp;
+        private final Domain domain;
+        private final long successfulUsersCount;
+        private final long failedUsersCount;
+
+        public AdditionalInformation(Instant timestamp, Domain domain, long successfulUsersCount, long failedUsersCount) {
+            this.timestamp = timestamp;
+            this.domain = domain;
+            this.successfulUsersCount = successfulUsersCount;
+            this.failedUsersCount = failedUsersCount;
+        }
+
+        public Domain getDomain() {
+            return domain;
+        }
+
+        public long getSuccessfulUsersCount() {
+            return successfulUsersCount;
+        }
+
+        public long getFailedUsersCount() {
+            return failedUsersCount;
+        }
+
+        @Override
+        public Instant timestamp() {
+            return timestamp;
+        }
+
+        @Override
+        public final boolean equals(Object o) {
+            if (o instanceof AdditionalInformation) {
+                AdditionalInformation that = (AdditionalInformation) o;
+
+                return Objects.equals(this.successfulUsersCount, that.successfulUsersCount)
+                    && Objects.equals(this.failedUsersCount, that.failedUsersCount)
+                    && Objects.equals(this.timestamp, that.timestamp)
+                    && Objects.equals(this.domain, that.domain);
+            }
+            return false;
+        }
+
+        @Override
+        public final int hashCode() {
+            return Objects.hash(timestamp, domain, successfulUsersCount, failedUsersCount);
+        }
+    }
+
+    static class Context {
+        private final AtomicLong successfulUsersCount;
+        private final AtomicLong failedUsersCount;
+
+        public Context() {
+            this.successfulUsersCount = new AtomicLong();
+            this.failedUsersCount = new AtomicLong();
+        }
+
+        private void increaseSuccessfulUsers() {
+            successfulUsersCount.incrementAndGet();
+        }
+
+        private void increaseFailedUsers() {
+            failedUsersCount.incrementAndGet();
+        }
+
+        public long getSuccessfulUsersCount() {
+            return successfulUsersCount.get();
+        }
+
+        public long getFailedUsersCount() {
+            return failedUsersCount.get();
+        }
+    }
+
+    private final Domain domain;
+    private final DeleteUserDataService deleteUserDataService;
+    private final UsersRepository usersRepository;
+    private final Context context;
+
+    public DeleteUsersDataOfDomainTask(DeleteUserDataService deleteUserDataService, Domain domain, UsersRepository usersRepository) {
+        this.deleteUserDataService = deleteUserDataService;
+        this.domain = domain;
+        this.usersRepository = usersRepository;
+        this.context = new Context();
+    }
+
+    @Override
+    public Result run() {
+        return Flux.from(usersRepository.listUsersOfADomainReactive(domain))
+            .flatMap(deleteUserData(), LOW_CONCURRENCY)
+            .reduce(Task::combine)
+            .switchIfEmpty(Mono.just(Result.COMPLETED))
+            .block();
+    }
+
+    private Function<Username, Publisher<Result>> deleteUserData() {
+        return username -> deleteUserDataService.performer().deleteUserData(username)
+            .then(Mono.fromCallable(() -> {
+                context.increaseSuccessfulUsers();
+                return Result.COMPLETED;
+            }))
+            .onErrorResume(error -> {
+                LOGGER.error("Error when deleting data of user {}", username.asString(), error);
+                context.increaseFailedUsers();
+                return Mono.just(Result.PARTIAL);
+            });
+    }
+
+    @Override
+    public TaskType type() {
+        return TYPE;
+    }
+
+    @Override
+    public Optional<TaskExecutionDetails.AdditionalInformation> details() {
+        return Optional.of(new AdditionalInformation(Clock.systemUTC().instant(), domain, context.getSuccessfulUsersCount(), context.getFailedUsersCount()));
+    }
+
+    public Domain getDomain() {
+        return domain;
+    }
+
+    @VisibleForTesting
+    Context getContext() {
+        return context;
+    }
+}
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java
new file mode 100644
index 0000000000..7bcd3c033d
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java
@@ -0,0 +1,81 @@
+/****************************************************************
+ * 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.Domain;
+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 DeleteUsersDataOfDomainTaskAdditionalInformationDTO implements AdditionalInformationDTO {
+    public static AdditionalInformationDTOModule<DeleteUsersDataOfDomainTask.AdditionalInformation, DeleteUsersDataOfDomainTaskAdditionalInformationDTO> module() {
+        return DTOModule.forDomainObject(DeleteUsersDataOfDomainTask.AdditionalInformation.class)
+            .convertToDTO(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.class)
+            .toDomainObjectConverter(dto -> new DeleteUsersDataOfDomainTask.AdditionalInformation(
+                dto.timestamp, Domain.of(dto.domain), dto.successfulUsersCount, dto.failedUsersCount))
+            .toDTOConverter((details, type) -> new DeleteUsersDataOfDomainTaskAdditionalInformationDTO(
+                type, details.getDomain().asString(), details.getSuccessfulUsersCount(), details.getFailedUsersCount(), details.timestamp()))
+            .typeName(DeleteUsersDataOfDomainTask.TYPE.asString())
+            .withFactory(AdditionalInformationDTOModule::new);
+    }
+
+    private final String type;
+    private final String domain;
+    private final long successfulUsersCount;
+    private final long failedUsersCount;
+    private final Instant timestamp;
+
+    public DeleteUsersDataOfDomainTaskAdditionalInformationDTO(@JsonProperty("type") String type,
+                                                               @JsonProperty("domain") String domain,
+                                                               @JsonProperty("successfulUsersCount") long successfulUsersCount,
+                                                               @JsonProperty("failedUsersCount") long failedUsersCount,
+                                                               @JsonProperty("timestamp") Instant timestamp) {
+        this.type = type;
+        this.domain = domain;
+        this.successfulUsersCount = successfulUsersCount;
+        this.failedUsersCount = failedUsersCount;
+        this.timestamp = timestamp;
+    }
+
+    public String getDomain() {
+        return domain;
+    }
+
+    public long getSuccessfulUsersCount() {
+        return successfulUsersCount;
+    }
+
+    public long getFailedUsersCount() {
+        return failedUsersCount;
+    }
+
+    public Instant getTimestamp() {
+        return timestamp;
+    }
+
+    @Override
+    public String getType() {
+        return type;
+    }
+}
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java
new file mode 100644
index 0000000000..df9f11bd6d
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskDTO.java
@@ -0,0 +1,68 @@
+/****************************************************************
+ * 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 org.apache.james.core.Domain;
+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 org.apache.james.user.api.UsersRepository;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class DeleteUsersDataOfDomainTaskDTO implements TaskDTO {
+
+    public static TaskDTOModule<DeleteUsersDataOfDomainTask, DeleteUsersDataOfDomainTaskDTO> module(DeleteUserDataService service, UsersRepository usersRepository) {
+        return DTOModule
+            .forDomainObject(DeleteUsersDataOfDomainTask.class)
+            .convertToDTO(DeleteUsersDataOfDomainTaskDTO.class)
+            .toDomainObjectConverter(dto -> dto.fromDTO(service, usersRepository))
+            .toDTOConverter(DeleteUsersDataOfDomainTaskDTO::toDTO)
+            .typeName(DeleteUsersDataOfDomainTask.TYPE.asString())
+            .withFactory(TaskDTOModule::new);
+    }
+
+    public static DeleteUsersDataOfDomainTaskDTO toDTO(DeleteUsersDataOfDomainTask domainObject, String typeName) {
+        return new DeleteUsersDataOfDomainTaskDTO(typeName,
+            domainObject.getDomain().asString());
+    }
+
+    private final String type;
+    private final String domain;
+
+    public DeleteUsersDataOfDomainTaskDTO(@JsonProperty("type") String type,
+                                          @JsonProperty("domain") String domain) {
+        this.type = type;
+        this.domain = domain;
+    }
+
+    public DeleteUsersDataOfDomainTask fromDTO(DeleteUserDataService service, UsersRepository usersRepository) {
+        return new DeleteUsersDataOfDomainTask(service, Domain.of(domain), usersRepository);
+    }
+
+    @Override
+    public String getType() {
+        return type;
+    }
+
+    public String getDomain() {
+        return domain;
+    }
+}
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java
index e4f0b9fe7b..baefeaf289 100644
--- a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java
@@ -35,41 +35,97 @@ import static org.mockito.Mockito.when;
 
 import java.net.InetAddress;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.james.core.Domain;
+import org.apache.james.core.Username;
 import org.apache.james.dnsservice.api.DNSService;
 import org.apache.james.domainlist.api.DomainList;
 import org.apache.james.domainlist.api.DomainListException;
 import org.apache.james.domainlist.lib.DomainListConfiguration;
 import org.apache.james.domainlist.memory.MemoryDomainList;
+import org.apache.james.json.DTOConverter;
 import org.apache.james.rrt.memory.MemoryRecipientRewriteTable;
+import org.apache.james.task.Hostname;
+import org.apache.james.task.MemoryTaskManager;
+import org.apache.james.user.api.DeleteUserDataTaskStep;
+import org.apache.james.user.api.UsersRepositoryException;
+import org.apache.james.user.memory.MemoryUsersRepository;
 import org.apache.james.webadmin.WebAdminServer;
 import org.apache.james.webadmin.WebAdminUtils;
+import org.apache.james.webadmin.service.DeleteUserDataService;
+import org.apache.james.webadmin.service.DeleteUsersDataOfDomainTaskAdditionalInformationDTO;
 import org.apache.james.webadmin.service.DomainAliasService;
+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.Nested;
 import org.junit.jupiter.api.Test;
+import org.reactivestreams.Publisher;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 
 import io.restassured.RestAssured;
 import io.restassured.http.ContentType;
+import reactor.core.publisher.Mono;
 
 class DomainsRoutesTest {
+    private static class RecordProcessedUsersStep implements DeleteUserDataTaskStep {
+        private final Set<Username> processedUsers = ConcurrentHashMap.newKeySet();
+
+        public RecordProcessedUsersStep() {
+        }
+
+        @Override
+        public StepName name() {
+            return new StepName("RecordProcessedUsersStep");
+        }
+
+        @Override
+        public int priority() {
+            return 0;
+        }
+
+        @Override
+        public Publisher<Void> deleteUserData(Username username) {
+            processedUsers.add(username);
+            return Mono.empty();
+        }
+    }
+
     private static final String DOMAIN = "domain";
     private static final String ALIAS_DOMAIN = "alias.domain";
     private static final String ALIAS_DOMAIN_2 = "alias.domain.bis";
     private static final String EXTERNAL_DOMAIN = "external.domain.tld";
 
     private WebAdminServer webAdminServer;
+    private MemoryUsersRepository usersRepository;
 
     private void createServer(DomainList domainList) {
+        MemoryTaskManager taskManager = new MemoryTaskManager(new Hostname("foo"));
+        DomainAliasService domainAliasService = new DomainAliasService(new MemoryRecipientRewriteTable(), domainList);
+        usersRepository = MemoryUsersRepository.withVirtualHosting(domainList);
+        webAdminServer = WebAdminUtils.createWebAdminServer(new DomainsRoutes(domainList, domainAliasService, new JsonTransformer(),
+                    new DeleteUserDataService(Set.of()), usersRepository, taskManager), new TasksRoutes(taskManager, new JsonTransformer(), DTOConverter.of(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module())))
+            .start();
+
+        RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer)
+            .setBasePath(DomainsRoutes.DOMAINS)
+            .build();
+    }
+
+    private void createServer(DomainList domainList, Set<DeleteUserDataTaskStep> steps) {
+        MemoryTaskManager taskManager = new MemoryTaskManager(new Hostname("foo"));
         DomainAliasService domainAliasService = new DomainAliasService(new MemoryRecipientRewriteTable(), domainList);
-        webAdminServer = WebAdminUtils.createWebAdminServer(new DomainsRoutes(domainList, domainAliasService, new JsonTransformer()))
+        DeleteUserDataService deleteUserDataService = new DeleteUserDataService(steps);
+        usersRepository = MemoryUsersRepository.withVirtualHosting(domainList);
+        webAdminServer = WebAdminUtils.createWebAdminServer(new DomainsRoutes(domainList, domainAliasService, new JsonTransformer(), deleteUserDataService, usersRepository, taskManager),
+                new TasksRoutes(taskManager, new JsonTransformer(), DTOConverter.of(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module())))
             .start();
 
         RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer)
@@ -82,6 +138,120 @@ class DomainsRoutesTest {
         webAdminServer.destroy();
     }
 
+    @Nested
+    class DeleteAllUsersDataTests {
+        private RecordProcessedUsersStep recordProcessedUsersStep;
+
+        @BeforeEach
+        void setUp() throws Exception {
+            DNSService dnsService = mock(DNSService.class);
+            when(dnsService.getHostName(any())).thenReturn("localhost");
+            when(dnsService.getLocalHost()).thenReturn(InetAddress.getByName("localhost"));
+            MemoryDomainList domainList = new MemoryDomainList(dnsService);
+            domainList.configure(DomainListConfiguration.builder()
+                .autoDetect(false)
+                .autoDetectIp(false)
+                .build());
+            domainList.addDomain(Domain.of("domain.tld"));
+
+            recordProcessedUsersStep = new RecordProcessedUsersStep();
+            Set<DeleteUserDataTaskStep> steps = ImmutableSet.of(new DeleteUserDataRoutesTest.StepImpl(new DeleteUserDataTaskStep.StepName("A"), 35, Mono.empty()),
+                recordProcessedUsersStep);
+            createServer(domainList, steps);
+        }
+
+        @Test
+        void shouldDeleteAllUsersDataOfTheDomain() throws UsersRepositoryException {
+            // GIVEN localhost domain has 2 users
+            usersRepository.addUser(Username.of("user1@localhost"), "secret");
+            usersRepository.addUser(Username.of("user2@localhost"), "secret");
+
+            // THEN delete all users data of localhost domain
+            String taskId = with()
+                .basePath("/domains")
+                .queryParam("action", "deleteData")
+                .post("/localhost")
+                .jsonPath()
+                .get("taskId");
+
+            given()
+                .basePath(TasksRoutes.BASE)
+            .when()
+                .get(taskId + "/await")
+            .then()
+                .body("type", is("DeleteUsersDataOfDomainTask"))
+                .body("status", is("completed"))
+                .body("additionalInformation.type", is("DeleteUsersDataOfDomainTask"))
+                .body("additionalInformation.domain", is("localhost"))
+                .body("additionalInformation.successfulUsersCount", is(2))
+                .body("additionalInformation.failedUsersCount", is(0));
+
+            // then should delete data of the 2 users
+            assertThat(recordProcessedUsersStep.processedUsers)
+                .containsExactlyInAnyOrder(Username.of("user1@localhost"), Username.of("user2@localhost"));
+        }
+
+        @Test
+        void shouldFailWhenInvalidAction() {
+            given()
+                .basePath("/domains")
+                .queryParam("action", "invalid")
+            .post("/localhost")
+            .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 'action': invalid. Supported values are [deleteData]"));
+        }
+
+        @Test
+        void shouldFailWhenNonExistingDomain() {
+            given()
+                .basePath("/domains")
+                .queryParam("action", "deleteData")
+            .post("/nonExistingDomain")
+            .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("'domainName' parameter should be an existing domain"));
+        }
+
+        @Test
+        void shouldNotDeleteUsersDataOfOtherDomains() throws UsersRepositoryException {
+            // GIVEN localhost domain has 2 users and domain.tld domain has 1 user
+            usersRepository.addUser(Username.of("user1@localhost"), "secret");
+            usersRepository.addUser(Username.of("user2@localhost"), "secret");
+            usersRepository.addUser(Username.of("user3@domain.tld"), "secret");
+
+            // WHEN delete users data of localhost domain
+            String taskId = with()
+                .basePath("/domains")
+                .queryParam("action", "deleteData")
+                .post("/localhost")
+                .jsonPath()
+                .get("taskId");
+
+            given()
+                .basePath(TasksRoutes.BASE)
+            .when()
+                .get(taskId + "/await")
+            .then()
+                .body("type", is("DeleteUsersDataOfDomainTask"))
+                .body("status", is("completed"))
+                .body("additionalInformation.type", is("DeleteUsersDataOfDomainTask"))
+                .body("additionalInformation.domain", is("localhost"))
+                .body("additionalInformation.successfulUsersCount", is(2))
+                .body("additionalInformation.failedUsersCount", is(0));
+
+            // THEN users data of domain.tld should not be clear
+            assertThat(recordProcessedUsersStep.processedUsers)
+                .doesNotContain(Username.of("user3@domain.tld"));
+        }
+    }
+
     @Nested
     class NormalBehaviour {
 
@@ -433,7 +603,7 @@ class DomainsRoutesTest {
                 with().put(DOMAIN);
 
                 when()
-                    .put(EXTERNAL_DOMAIN + "/aliases/" + DOMAIN).prettyPeek()
+                    .put(EXTERNAL_DOMAIN + "/aliases/" + DOMAIN)
                 .then()
                     .contentType(ContentType.JSON)
                     .statusCode(HttpStatus.NO_CONTENT_204);
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java
new file mode 100644
index 0000000000..8de0149656
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java
@@ -0,0 +1,93 @@
+/****************************************************************
+ * 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.mockito.Mockito.mock;
+
+import java.time.Instant;
+
+import org.apache.james.JsonSerializationVerifier;
+import org.apache.james.core.Domain;
+import org.apache.james.core.Username;
+import org.apache.james.user.api.DeleteUserDataTaskStep;
+import org.apache.james.user.api.UsersRepository;
+import org.junit.jupiter.api.Test;
+import org.reactivestreams.Publisher;
+
+import com.google.common.collect.ImmutableSet;
+
+import reactor.core.publisher.Mono;
+
+class DeleteUsersDataOfDomainTaskSerializationTest {
+    private static final Instant TIMESTAMP = Instant.parse("2018-11-13T12:00:55Z");
+    private static final Domain DOMAIN = Domain.of("domain");
+    private static final long SUCCESSFUL_USERS_COUNT = 99L;
+    private static final long FAILED_USERS_COUNT = 1L;
+    private static final DeleteUserDataTaskStep.StepName STEP_A = new DeleteUserDataTaskStep.StepName("A");
+    private static final DeleteUserDataTaskStep.StepName STEP_B = new DeleteUserDataTaskStep.StepName("B");
+    private static final DeleteUserDataTaskStep.StepName STEP_C = new DeleteUserDataTaskStep.StepName("C");
+    private static final DeleteUserDataTaskStep.StepName STEP_D = new DeleteUserDataTaskStep.StepName("D");
+    private static final DeleteUserDataTaskStep A = asStep(STEP_A);
+    private static final DeleteUserDataTaskStep B = asStep(STEP_B);
+    private static final DeleteUserDataTaskStep C = asStep(STEP_C);
+    private static final DeleteUserDataTaskStep D = asStep(STEP_D);
+
+    private static DeleteUserDataTaskStep asStep(DeleteUserDataTaskStep.StepName name) {
+        return new DeleteUserDataTaskStep() {
+            @Override
+            public StepName name() {
+                return name;
+            }
+
+            @Override
+            public int priority() {
+                return 0;
+            }
+
+            @Override
+            public Publisher<Void> deleteUserData(Username username) {
+                return Mono.empty();
+            }
+        };
+    }
+
+    private static final String SERIALIZED_TASK = "{\"type\":\"DeleteUsersDataOfDomainTask\",\"domain\":\"domain\"}";
+    private static final String SERIALIZED_ADDITIONAL_INFORMATION = "{\"type\":\"DeleteUsersDataOfDomainTask\",\"domain\":\"domain\",\"successfulUsersCount\":99,\"failedUsersCount\":1,\"timestamp\":\"2018-11-13T12:00:55Z\"}";
+
+    private static final DeleteUserDataService SERVICE = new DeleteUserDataService(ImmutableSet.of(A, B, C, D));
+
+    @Test
+    void taskShouldBeSerializable() throws Exception {
+        UsersRepository usersRepository = mock(UsersRepository.class);
+        JsonSerializationVerifier.dtoModule(DeleteUsersDataOfDomainTaskDTO.module(SERVICE, usersRepository))
+            .bean(new DeleteUsersDataOfDomainTask(SERVICE, DOMAIN, usersRepository))
+            .json(SERIALIZED_TASK)
+            .verify();
+    }
+
+    @Test
+    void additionalInformationShouldBeSerializable() throws Exception {
+        JsonSerializationVerifier.dtoModule(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module())
+            .bean(new DeleteUsersDataOfDomainTask.AdditionalInformation(
+                TIMESTAMP, DOMAIN, SUCCESSFUL_USERS_COUNT, FAILED_USERS_COUNT))
+            .json(SERIALIZED_ADDITIONAL_INFORMATION)
+            .verify();
+    }
+}
\ No newline at end of file
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java
new file mode 100644
index 0000000000..f9bc9cecea
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java
@@ -0,0 +1,194 @@
+/****************************************************************
+ * 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.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.InetAddress;
+import java.util.Set;
+
+import org.apache.james.core.Domain;
+import org.apache.james.core.Username;
+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.task.Task;
+import org.apache.james.user.api.DeleteUserDataTaskStep;
+import org.apache.james.user.api.UsersRepositoryException;
+import org.apache.james.user.memory.MemoryUsersRepository;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.reactivestreams.Publisher;
+
+import reactor.core.publisher.Mono;
+
+class DeleteUsersDataOfDomainTaskTest {
+    public static class FailureStepUponUser implements DeleteUserDataTaskStep {
+        private final Set<Username> usersToBeFailed;
+
+        public FailureStepUponUser(Set<Username> usersToBeFailed) {
+            this.usersToBeFailed = usersToBeFailed;
+        }
+
+        @Override
+        public StepName name() {
+            return new StepName("FailureStepUponUser");
+        }
+
+        @Override
+        public int priority() {
+            return 0;
+        }
+
+        @Override
+        public Publisher<Void> deleteUserData(Username username) {
+            if (usersToBeFailed.contains(username)) {
+                return Mono.error(new RuntimeException());
+            }
+            return Mono.empty();
+        }
+    }
+
+    private static final Domain DOMAIN_1 = Domain.of("domain1.tld");
+    private static final Domain DOMAIN_2 = Domain.of("domain2.tld");
+
+    private DeleteUserDataService service;
+    private MemoryUsersRepository usersRepository;
+
+    @BeforeEach
+    void setup() throws Exception {
+        DNSService dnsService = mock(DNSService.class);
+        when(dnsService.getHostName(any())).thenReturn("localhost");
+        when(dnsService.getLocalHost()).thenReturn(InetAddress.getByName("localhost"));
+        MemoryDomainList domainList = new MemoryDomainList(dnsService);
+        domainList.configure(DomainListConfiguration.builder()
+            .autoDetect(false)
+            .autoDetectIp(false)
+            .build());
+        domainList.addDomain(DOMAIN_1);
+        domainList.addDomain(DOMAIN_2);
+
+        usersRepository = MemoryUsersRepository.withVirtualHosting(domainList);
+    }
+
+    @Test
+    void shouldCountSuccessfulUsers() throws UsersRepositoryException {
+        // GIVEN DOMAIN1 has 2 users
+        usersRepository.addUser(Username.of("user1@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user2@domain1.tld"), "password");
+
+        // WHEN run task for DOMAIN1
+        service = new DeleteUserDataService(Set.of());
+        DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository);
+        Task.Result result = task.run();
+
+        // THEN should count successful DOMAIN1 users
+        SoftAssertions.assertSoftly(softly -> {
+            softly.assertThat(result).isEqualTo(Task.Result.COMPLETED);
+            softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L);
+            softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(0L);
+        });
+    }
+
+    @Test
+    void shouldCountOnlySuccessfulUsersOfRequestedDomain() throws UsersRepositoryException {
+        // GIVEN DOMAIN1 has 2 users and DOMAIN2 has 1 user
+        usersRepository.addUser(Username.of("user1@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user2@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user3@domain2.tld"), "password");
+
+        // WHEN run task for DOMAIN1
+        service = new DeleteUserDataService(Set.of());
+        DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository);
+        Task.Result result = task.run();
+
+        // THEN should count only successful DOMAIN1 users
+        SoftAssertions.assertSoftly(softly -> {
+            softly.assertThat(result).isEqualTo(Task.Result.COMPLETED);
+            softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L);
+            softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(0L);
+        });
+    }
+
+    @Test
+    void shouldCountFailedUsers() throws UsersRepositoryException {
+        // GIVEN DOMAIN1 has 2 users
+        usersRepository.addUser(Username.of("user1@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user2@domain1.tld"), "password");
+
+        // WHEN run task for DOMAIN1
+        Set<Username> usersTobeFailed = Set.of(Username.of("user1@domain1.tld"), Username.of("user2@domain1.tld"));
+        service = new DeleteUserDataService(Set.of(new FailureStepUponUser(usersTobeFailed)));
+        DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository);
+        Task.Result result = task.run();
+
+        // THEN should count failed DOMAIN1 users
+        SoftAssertions.assertSoftly(softly -> {
+            softly.assertThat(result).isEqualTo(Task.Result.PARTIAL);
+            softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(0L);
+            softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(2L);
+        });
+    }
+
+    @Test
+    void shouldCountOnlyFailedUsersOfRequestedDomain() throws UsersRepositoryException {
+        // GIVEN DOMAIN1 has 2 users and DOMAIN2 has 1 user
+        usersRepository.addUser(Username.of("user1@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user2@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user3@domain2.tld"), "password");
+
+        // WHEN run task for DOMAIN1
+        Set<Username> usersTobeFailed = Set.of(Username.of("user1@domain1.tld"), Username.of("user2@domain1.tld"), Username.of("user3@domain2.tld"));
+        service = new DeleteUserDataService(Set.of(new FailureStepUponUser(usersTobeFailed)));
+        DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository);
+        Task.Result result = task.run();
+
+        // THEN should count only failed DOMAIN1 users
+        SoftAssertions.assertSoftly(softly -> {
+            softly.assertThat(result).isEqualTo(Task.Result.PARTIAL);
+            softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(0L);
+            softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(2L);
+        });
+    }
+
+    @Test
+    void mixedSuccessfulAndFailedUsersCase() throws UsersRepositoryException {
+        // GIVEN DOMAIN1 has 3 users
+        usersRepository.addUser(Username.of("user1@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user2@domain1.tld"), "password");
+        usersRepository.addUser(Username.of("user3@domain1.tld"), "password");
+
+        // WHEN run task for DOMAIN1
+        Set<Username> usersTobeFailed = Set.of(Username.of("user1@domain1.tld"));
+        service = new DeleteUserDataService(Set.of(new FailureStepUponUser(usersTobeFailed)));
+        DeleteUsersDataOfDomainTask task = new DeleteUsersDataOfDomainTask(service, DOMAIN_1, usersRepository);
+        Task.Result result = task.run();
+
+        // THEN should count both successful and failed users
+        SoftAssertions.assertSoftly(softly -> {
+            softly.assertThat(result).isEqualTo(Task.Result.PARTIAL);
+            softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L);
+            softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(1L);
+        });
+    }
+}
diff --git a/src/site/markdown/server/manage-webadmin.md b/src/site/markdown/server/manage-webadmin.md
index 329cd488ad..5864d0a644 100644
--- a/src/site/markdown/server/manage-webadmin.md
+++ b/src/site/markdown/server/manage-webadmin.md
@@ -178,6 +178,7 @@ Response codes:
    - [Get the list of aliases for a domain](#Get_the_list_of_aliases_for_a_domain)
    - [Create an alias for a domain](#Create_an_alias_for_a_domain)
    - [Delete an alias for a domain](#Delete_an_alias_for_a_domain)
+   - [Delete all users data of a domain](#delete-all-users-data-of-a-domain)
 
 ### Create a domain
 
@@ -302,6 +303,33 @@ Response codes:
  - 400: source, domain and destination domain are the same
  - 404: `source.domain.tld` are not part of handled domains.
 
+### Delete all users data of a domain
+
+```
+curl -XPOST http://ip:port/domains/{domainToBeUsed}?action=deleteData
+```
+
+Would create a task that deletes data of all users of the domain.
+
+[More details about endpoints returning a task](#_endpoints_returning_a_task).
+
+Response codes:
+
+* 201: Success. Corresponding task id is returned.
+* 400: Error in the request. Details can be found in the reported error.
+
+The scheduled task will have the following type `DeleteUsersDataOfDomainTask` and the following `additionalInformation`:
+
+```
+{
+        "type": "DeleteUsersDataOfDomainTask",
+        "domain": "domain.tld",
+        "successfulUsersCount": 2,
+        "failedUsersCount": 1,
+        "timestamp": "2023-05-22T08:52:47.076261Z"
+}
+```
+
 ## Administrating users
 
    - [Create a user](#Create_a_user)


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


[james-project] 02/03: JAMES-3909 Add UsersRepository::listUsersOfADomainReactive

Posted by bt...@apache.org.
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 1baee47628621729c0e9da5e1a2f2724e1ef86fe
Author: Quan Tran <hq...@linagora.com>
AuthorDate: Mon May 22 10:58:23 2023 +0700

    JAMES-3909 Add UsersRepository::listUsersOfADomainReactive
    
    The user listing + filtering by domain is not really optimized. Therefore, it is a default method for now, and people can optimize/override it later.
---
 .../java/org/apache/james/user/api/UsersRepository.java  | 10 ++++++++++
 .../apache/james/user/lib/UsersRepositoryContract.java   | 16 ++++++++++++++++
 2 files changed, 26 insertions(+)

diff --git a/server/data/data-api/src/main/java/org/apache/james/user/api/UsersRepository.java b/server/data/data-api/src/main/java/org/apache/james/user/api/UsersRepository.java
index 34c99decef..4a3e67523b 100644
--- a/server/data/data-api/src/main/java/org/apache/james/user/api/UsersRepository.java
+++ b/server/data/data-api/src/main/java/org/apache/james/user/api/UsersRepository.java
@@ -21,11 +21,14 @@ package org.apache.james.user.api;
 
 import java.util.Iterator;
 
+import org.apache.james.core.Domain;
 import org.apache.james.core.MailAddress;
 import org.apache.james.core.Username;
 import org.apache.james.user.api.model.User;
 import org.reactivestreams.Publisher;
 
+import reactor.core.publisher.Flux;
+
 /**
  * Interface for a repository of users. A repository represents a logical
  * grouping of users, typically by common purpose. E.g. the users served by an
@@ -170,4 +173,11 @@ public interface UsersRepository {
             throw new UsersRepositoryException(username.asString() + " username candidate do not match the virtualHosting strategy");
         }
     }
+
+    default Publisher<Username> listUsersOfADomainReactive(Domain domain) {
+        return Flux.from(listReactive())
+            .filter(username -> username.getDomainPart()
+                .map(domain::equals)
+                .orElse(false));
+    }
 }
diff --git a/server/data/data-library/src/test/java/org/apache/james/user/lib/UsersRepositoryContract.java b/server/data/data-library/src/test/java/org/apache/james/user/lib/UsersRepositoryContract.java
index 8fe9e17c71..f13dfa473f 100644
--- a/server/data/data-library/src/test/java/org/apache/james/user/lib/UsersRepositoryContract.java
+++ b/server/data/data-library/src/test/java/org/apache/james/user/lib/UsersRepositoryContract.java
@@ -50,6 +50,8 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import reactor.core.publisher.Flux;
+
 public interface UsersRepositoryContract {
 
     class UserRepositoryExtension implements BeforeEachCallback, ParameterResolver {
@@ -551,6 +553,20 @@ public interface UsersRepositoryContract {
             assertThat(actual).isTrue();
         }
 
+        @Test
+        default void listUsersOfADomainShouldNotListOtherDomainUsers(TestSystem testSystem) throws Exception {
+            testSystem.domainList.addDomain(Domain.of("domain1.tld"));
+            testee().addUser(Username.of("user1@domain1.tld"), "password");
+
+            testSystem.domainList.addDomain(Domain.of("domain2.tld"));
+            testee().addUser(Username.of("user2@domain2.tld"), "password");
+
+            assertThat(Flux.from(testee().listUsersOfADomainReactive(Domain.of("domain1.tld")))
+                .collectList()
+                .block())
+                .containsOnly(Username.of("user1@domain1.tld"));
+        }
+
         @Test
         default void addUserShouldThrowWhenUserDoesNotBelongToDomainList(TestSystem testSystem) {
             assertThatThrownBy(() -> testee().addUser(testSystem.userWithUnknownDomain, "password"))


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


[james-project] 03/03: JAMES-3909 Add listing failedUsers to task additional information

Posted by bt...@apache.org.
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 375aebbd21cb4bf16a9a9cc74df221a86a6abc83
Author: Quan Tran <hq...@linagora.com>
AuthorDate: Mon May 22 16:52:30 2023 +0700

    JAMES-3909 Add listing failedUsers to task additional information
---
 .../docs/modules/ROOT/pages/operate/webadmin.adoc  |  3 +++
 .../service/DeleteUsersDataOfDomainTask.java       | 27 ++++++++++++++++++++--
 ...rsDataOfDomainTaskAdditionalInformationDTO.java | 26 +++++++++++++++++++--
 .../james/webadmin/routes/DomainsRoutesTest.java   |  7 ++++--
 ...leteUsersDataOfDomainTaskSerializationTest.java | 13 +++++++++--
 .../service/DeleteUsersDataOfDomainTaskTest.java   |  5 ++++
 src/site/markdown/server/manage-webadmin.md        |  3 +++
 7 files changed, 76 insertions(+), 8 deletions(-)

diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc
index 0a69f5618f..fa1a311569 100644
--- a/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc
+++ b/server/apps/distributed-app/docs/modules/ROOT/pages/operate/webadmin.adoc
@@ -478,10 +478,13 @@ The scheduled task will have the following type `DeleteUsersDataOfDomainTask` an
         "domain": "domain.tld",
         "successfulUsersCount": 2,
         "failedUsersCount": 1,
+        "failedUsers": ["faileduser@domain.tld"],
         "timestamp": "2023-05-22T08:52:47.076261Z"
 }
 ....
 
+Notes: `failedUsers` only lists maximum 100 failed users.
+
 == Administrating users
 
 === Create a user
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java
index 84487aade6..146fa7a787 100644
--- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTask.java
@@ -23,6 +23,8 @@ import java.time.Clock;
 import java.time.Instant;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 
@@ -42,18 +44,21 @@ import reactor.core.publisher.Mono;
 public class DeleteUsersDataOfDomainTask implements Task {
     static final TaskType TYPE = TaskType.of("DeleteUsersDataOfDomainTask");
     private static final int LOW_CONCURRENCY = 2;
+    private static final int MAX_STORED_FAILED_USERS = 100;
 
     public static class AdditionalInformation implements TaskExecutionDetails.AdditionalInformation {
         private final Instant timestamp;
         private final Domain domain;
         private final long successfulUsersCount;
         private final long failedUsersCount;
+        private final Set<Username> failedUsers;
 
-        public AdditionalInformation(Instant timestamp, Domain domain, long successfulUsersCount, long failedUsersCount) {
+        public AdditionalInformation(Instant timestamp, Domain domain, long successfulUsersCount, long failedUsersCount, Set<Username> failedUsers) {
             this.timestamp = timestamp;
             this.domain = domain;
             this.successfulUsersCount = successfulUsersCount;
             this.failedUsersCount = failedUsersCount;
+            this.failedUsers = failedUsers;
         }
 
         public Domain getDomain() {
@@ -68,6 +73,10 @@ public class DeleteUsersDataOfDomainTask implements Task {
             return failedUsersCount;
         }
 
+        public Set<Username> getFailedUsers() {
+            return failedUsers;
+        }
+
         @Override
         public Instant timestamp() {
             return timestamp;
@@ -95,10 +104,12 @@ public class DeleteUsersDataOfDomainTask implements Task {
     static class Context {
         private final AtomicLong successfulUsersCount;
         private final AtomicLong failedUsersCount;
+        private final Set<Username> failedUsers;
 
         public Context() {
             this.successfulUsersCount = new AtomicLong();
             this.failedUsersCount = new AtomicLong();
+            this.failedUsers = ConcurrentHashMap.newKeySet();
         }
 
         private void increaseSuccessfulUsers() {
@@ -109,6 +120,10 @@ public class DeleteUsersDataOfDomainTask implements Task {
             failedUsersCount.incrementAndGet();
         }
 
+        private void addFailedUser(Username username) {
+            failedUsers.add(username);
+        }
+
         public long getSuccessfulUsersCount() {
             return successfulUsersCount.get();
         }
@@ -116,6 +131,10 @@ public class DeleteUsersDataOfDomainTask implements Task {
         public long getFailedUsersCount() {
             return failedUsersCount.get();
         }
+
+        public Set<Username> getFailedUsers() {
+            return failedUsers;
+        }
     }
 
     private final Domain domain;
@@ -148,6 +167,9 @@ public class DeleteUsersDataOfDomainTask implements Task {
             .onErrorResume(error -> {
                 LOGGER.error("Error when deleting data of user {}", username.asString(), error);
                 context.increaseFailedUsers();
+                if (context.failedUsers.size() < MAX_STORED_FAILED_USERS) {
+                    context.addFailedUser(username);
+                }
                 return Mono.just(Result.PARTIAL);
             });
     }
@@ -159,7 +181,8 @@ public class DeleteUsersDataOfDomainTask implements Task {
 
     @Override
     public Optional<TaskExecutionDetails.AdditionalInformation> details() {
-        return Optional.of(new AdditionalInformation(Clock.systemUTC().instant(), domain, context.getSuccessfulUsersCount(), context.getFailedUsersCount()));
+        return Optional.of(new AdditionalInformation(Clock.systemUTC().instant(), domain, context.getSuccessfulUsersCount(),
+            context.getFailedUsersCount(), context.getFailedUsers()));
     }
 
     public Domain getDomain() {
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java
index 7bcd3c033d..f601fe8785 100644
--- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskAdditionalInformationDTO.java
@@ -20,8 +20,11 @@
 package org.apache.james.webadmin.service;
 
 import java.time.Instant;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import org.apache.james.core.Domain;
+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;
@@ -33,28 +36,43 @@ public class DeleteUsersDataOfDomainTaskAdditionalInformationDTO implements Addi
         return DTOModule.forDomainObject(DeleteUsersDataOfDomainTask.AdditionalInformation.class)
             .convertToDTO(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.class)
             .toDomainObjectConverter(dto -> new DeleteUsersDataOfDomainTask.AdditionalInformation(
-                dto.timestamp, Domain.of(dto.domain), dto.successfulUsersCount, dto.failedUsersCount))
+                dto.timestamp, Domain.of(dto.domain), dto.successfulUsersCount, dto.failedUsersCount, toSetUsername(dto.failedUsers)))
             .toDTOConverter((details, type) -> new DeleteUsersDataOfDomainTaskAdditionalInformationDTO(
-                type, details.getDomain().asString(), details.getSuccessfulUsersCount(), details.getFailedUsersCount(), details.timestamp()))
+                type, details.getDomain().asString(), details.getSuccessfulUsersCount(), details.getFailedUsersCount(), toSetString(details.getFailedUsers()), details.timestamp()))
             .typeName(DeleteUsersDataOfDomainTask.TYPE.asString())
             .withFactory(AdditionalInformationDTOModule::new);
     }
 
+    private static Set<Username> toSetUsername(Set<String> usernames) {
+        return usernames.stream()
+            .map(Username::of)
+            .collect(Collectors.toSet());
+    }
+
+    private static Set<String> toSetString(Set<Username> usernames) {
+        return usernames.stream()
+            .map(Username::asString)
+            .collect(Collectors.toSet());
+    }
+
     private final String type;
     private final String domain;
     private final long successfulUsersCount;
     private final long failedUsersCount;
+    private final Set<String> failedUsers;
     private final Instant timestamp;
 
     public DeleteUsersDataOfDomainTaskAdditionalInformationDTO(@JsonProperty("type") String type,
                                                                @JsonProperty("domain") String domain,
                                                                @JsonProperty("successfulUsersCount") long successfulUsersCount,
                                                                @JsonProperty("failedUsersCount") long failedUsersCount,
+                                                               @JsonProperty("failedUsers") Set<String> failedUsers,
                                                                @JsonProperty("timestamp") Instant timestamp) {
         this.type = type;
         this.domain = domain;
         this.successfulUsersCount = successfulUsersCount;
         this.failedUsersCount = failedUsersCount;
+        this.failedUsers = failedUsers;
         this.timestamp = timestamp;
     }
 
@@ -70,6 +88,10 @@ public class DeleteUsersDataOfDomainTaskAdditionalInformationDTO implements Addi
         return failedUsersCount;
     }
 
+    public Set<String> getFailedUsers() {
+        return failedUsers;
+    }
+
     public Instant getTimestamp() {
         return timestamp;
     }
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java
index baefeaf289..82b88af391 100644
--- a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DomainsRoutesTest.java
@@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.hasSize;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doThrow;
@@ -184,7 +185,8 @@ class DomainsRoutesTest {
                 .body("additionalInformation.type", is("DeleteUsersDataOfDomainTask"))
                 .body("additionalInformation.domain", is("localhost"))
                 .body("additionalInformation.successfulUsersCount", is(2))
-                .body("additionalInformation.failedUsersCount", is(0));
+                .body("additionalInformation.failedUsersCount", is(0))
+                .body("additionalInformation.failedUsers", empty());
 
             // then should delete data of the 2 users
             assertThat(recordProcessedUsersStep.processedUsers)
@@ -244,7 +246,8 @@ class DomainsRoutesTest {
                 .body("additionalInformation.type", is("DeleteUsersDataOfDomainTask"))
                 .body("additionalInformation.domain", is("localhost"))
                 .body("additionalInformation.successfulUsersCount", is(2))
-                .body("additionalInformation.failedUsersCount", is(0));
+                .body("additionalInformation.failedUsersCount", is(0))
+                .body("additionalInformation.failedUsers", empty());
 
             // THEN users data of domain.tld should not be clear
             assertThat(recordProcessedUsersStep.processedUsers)
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java
index 8de0149656..9d1cf3ffb5 100644
--- a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskSerializationTest.java
@@ -22,6 +22,7 @@ package org.apache.james.webadmin.service;
 import static org.mockito.Mockito.mock;
 
 import java.time.Instant;
+import java.util.Set;
 
 import org.apache.james.JsonSerializationVerifier;
 import org.apache.james.core.Domain;
@@ -40,6 +41,7 @@ class DeleteUsersDataOfDomainTaskSerializationTest {
     private static final Domain DOMAIN = Domain.of("domain");
     private static final long SUCCESSFUL_USERS_COUNT = 99L;
     private static final long FAILED_USERS_COUNT = 1L;
+    private static final Set<Username> FAILED_USERS = Set.of(Username.of("faileduser@domain"));
     private static final DeleteUserDataTaskStep.StepName STEP_A = new DeleteUserDataTaskStep.StepName("A");
     private static final DeleteUserDataTaskStep.StepName STEP_B = new DeleteUserDataTaskStep.StepName("B");
     private static final DeleteUserDataTaskStep.StepName STEP_C = new DeleteUserDataTaskStep.StepName("C");
@@ -69,7 +71,14 @@ class DeleteUsersDataOfDomainTaskSerializationTest {
     }
 
     private static final String SERIALIZED_TASK = "{\"type\":\"DeleteUsersDataOfDomainTask\",\"domain\":\"domain\"}";
-    private static final String SERIALIZED_ADDITIONAL_INFORMATION = "{\"type\":\"DeleteUsersDataOfDomainTask\",\"domain\":\"domain\",\"successfulUsersCount\":99,\"failedUsersCount\":1,\"timestamp\":\"2018-11-13T12:00:55Z\"}";
+    private static final String SERIALIZED_ADDITIONAL_INFORMATION = "{\n" +
+        "  \"type\": \"DeleteUsersDataOfDomainTask\",\n" +
+        "  \"domain\": \"domain\",\n" +
+        "  \"successfulUsersCount\": 99,\n" +
+        "  \"failedUsersCount\": 1,\n" +
+        "  \"failedUsers\": [\"faileduser@domain\"],\n" +
+        "  \"timestamp\": \"2018-11-13T12:00:55Z\"\n" +
+        "}";
 
     private static final DeleteUserDataService SERVICE = new DeleteUserDataService(ImmutableSet.of(A, B, C, D));
 
@@ -86,7 +95,7 @@ class DeleteUsersDataOfDomainTaskSerializationTest {
     void additionalInformationShouldBeSerializable() throws Exception {
         JsonSerializationVerifier.dtoModule(DeleteUsersDataOfDomainTaskAdditionalInformationDTO.module())
             .bean(new DeleteUsersDataOfDomainTask.AdditionalInformation(
-                TIMESTAMP, DOMAIN, SUCCESSFUL_USERS_COUNT, FAILED_USERS_COUNT))
+                TIMESTAMP, DOMAIN, SUCCESSFUL_USERS_COUNT, FAILED_USERS_COUNT, FAILED_USERS))
             .json(SERIALIZED_ADDITIONAL_INFORMATION)
             .verify();
     }
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java
index f9bc9cecea..d5650d1bde 100644
--- a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/service/DeleteUsersDataOfDomainTaskTest.java
@@ -147,6 +147,8 @@ class DeleteUsersDataOfDomainTaskTest {
             softly.assertThat(result).isEqualTo(Task.Result.PARTIAL);
             softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(0L);
             softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(2L);
+            softly.assertThat(task.getContext().getFailedUsers())
+                .containsExactlyInAnyOrder(Username.of("user1@domain1.tld"), Username.of("user2@domain1.tld"));
         });
     }
 
@@ -168,6 +170,8 @@ class DeleteUsersDataOfDomainTaskTest {
             softly.assertThat(result).isEqualTo(Task.Result.PARTIAL);
             softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(0L);
             softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(2L);
+            softly.assertThat(task.getContext().getFailedUsers())
+                .containsExactlyInAnyOrder(Username.of("user1@domain1.tld"), Username.of("user2@domain1.tld"));
         });
     }
 
@@ -189,6 +193,7 @@ class DeleteUsersDataOfDomainTaskTest {
             softly.assertThat(result).isEqualTo(Task.Result.PARTIAL);
             softly.assertThat(task.getContext().getSuccessfulUsersCount()).isEqualTo(2L);
             softly.assertThat(task.getContext().getFailedUsersCount()).isEqualTo(1L);
+            softly.assertThat(task.getContext().getFailedUsers()).containsExactly(Username.of("user1@domain1.tld"));
         });
     }
 }
diff --git a/src/site/markdown/server/manage-webadmin.md b/src/site/markdown/server/manage-webadmin.md
index 5864d0a644..5ea35c2dd3 100644
--- a/src/site/markdown/server/manage-webadmin.md
+++ b/src/site/markdown/server/manage-webadmin.md
@@ -326,10 +326,13 @@ The scheduled task will have the following type `DeleteUsersDataOfDomainTask` an
         "domain": "domain.tld",
         "successfulUsersCount": 2,
         "failedUsersCount": 1,
+        "failedUsers": ["faileduser@domain.tld"],
         "timestamp": "2023-05-22T08:52:47.076261Z"
 }
 ```
 
+Notes: `failedUsers` only lists maximum 100 failed users.
+
 ## Administrating users
 
    - [Create a user](#Create_a_user)


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