Merge pull request #546 from chibenwa/JAMES-3544-list-buckets

JAMES-3544 Blob store should allow to list bucket
diff --git a/server/blob/blob-aes/src/main/java/org/apache/james/blob/aes/AESBlobStoreDAO.java b/server/blob/blob-aes/src/main/java/org/apache/james/blob/aes/AESBlobStoreDAO.java
index 8f45e0f..aa4a6b6 100644
--- a/server/blob/blob-aes/src/main/java/org/apache/james/blob/aes/AESBlobStoreDAO.java
+++ b/server/blob/blob-aes/src/main/java/org/apache/james/blob/aes/AESBlobStoreDAO.java
@@ -131,4 +131,9 @@
     public Publisher<Void> deleteBucket(BucketName bucketName) {
         return underlying.deleteBucket(bucketName);
     }
+
+    @Override
+    public Publisher<BucketName> listBuckets() {
+        return underlying.listBuckets();
+    }
 }
diff --git a/server/blob/blob-aes/src/test/java/org/apache/james/blob/aes/AESBlobStoreDAOTest.java b/server/blob/blob-aes/src/test/java/org/apache/james/blob/aes/AESBlobStoreDAOTest.java
index 8be3728..ad58cd0 100644
--- a/server/blob/blob-aes/src/test/java/org/apache/james/blob/aes/AESBlobStoreDAOTest.java
+++ b/server/blob/blob-aes/src/test/java/org/apache/james/blob/aes/AESBlobStoreDAOTest.java
@@ -30,6 +30,7 @@
 import org.apache.james.blob.api.BlobStoreDAOContract;
 import org.apache.james.blob.memory.MemoryBlobStoreDAO;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 import com.google.common.io.ByteSource;
@@ -83,4 +84,10 @@
 
         assertThat(bytes).isNotEqualTo(SHORT_BYTEARRAY);
     }
+
+    @Override
+    @Disabled("Not supported by the Memory blob store")
+    public void listBucketsShouldReturnBucketsWithNoBlob() {
+
+    }
 }
\ No newline at end of file
diff --git a/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStore.java b/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStore.java
index 3f7cd67..cf315d2 100644
--- a/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStore.java
+++ b/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStore.java
@@ -58,6 +58,8 @@
 
     BucketName getDefaultBucketName();
 
+    Publisher<BucketName> listBuckets();
+
     Publisher<Void> deleteBucket(BucketName bucketName);
 
     Publisher<Boolean> delete(BucketName bucketName, BlobId blobId);
diff --git a/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStoreDAO.java b/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStoreDAO.java
index f0e2844..5288284 100644
--- a/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStoreDAO.java
+++ b/server/blob/blob-api/src/main/java/org/apache/james/blob/api/BlobStoreDAO.java
@@ -99,4 +99,6 @@
      *  otherwise an IOObjectStoreException in its error channel
      */
     Publisher<Void> deleteBucket(BucketName bucketName);
+
+    Publisher<BucketName> listBuckets();
 }
diff --git a/server/blob/blob-api/src/main/java/org/apache/james/blob/api/MetricableBlobStore.java b/server/blob/blob-api/src/main/java/org/apache/james/blob/api/MetricableBlobStore.java
index 8c57fec..4b104ac 100644
--- a/server/blob/blob-api/src/main/java/org/apache/james/blob/api/MetricableBlobStore.java
+++ b/server/blob/blob-api/src/main/java/org/apache/james/blob/api/MetricableBlobStore.java
@@ -102,4 +102,9 @@
         return metricFactory.decoratePublisherWithTimerMetric(DELETE_TIMER_NAME, blobStoreImpl.delete(bucketName, blobId));
     }
 
+
+    @Override
+    public Publisher<BucketName> listBuckets() {
+        return blobStoreImpl.listBuckets();
+    }
 }
diff --git a/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreContract.java b/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreContract.java
index 75b0706..e616ee2 100644
--- a/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreContract.java
+++ b/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreContract.java
@@ -31,6 +31,7 @@
 import org.apache.james.util.concurrency.ConcurrentTestRunner;
 import org.junit.jupiter.api.Test;
 
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 public interface BucketBlobStoreContract {
@@ -168,4 +169,33 @@
             .operationCount(10)
             .runSuccessfullyWithin(Duration.ofMinutes(1));
     }
+
+    @Test
+    default void listBucketsShouldReturnDefaultBucket() {
+        BlobStore store = testee();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .containsOnly(store.getDefaultBucketName());
+    }
+
+    @Test
+    default void listBucketsShouldReturnACustomBucket() {
+        BlobStore store = testee();
+
+        Mono.from(store.save(CUSTOM, SHORT_BYTEARRAY, LOW_COST)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .containsOnly(store.getDefaultBucketName(), CUSTOM);
+    }
+
+    @Test
+    default void listBucketsShouldNotReturnADeletedBucket() {
+        BlobStore store = testee();
+
+        Mono.from(store.save(CUSTOM, SHORT_BYTEARRAY, LOW_COST)).block();
+        Mono.from(store.deleteBucket(CUSTOM)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .containsOnly(store.getDefaultBucketName());
+    }
 }
diff --git a/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreDAOContract.java b/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreDAOContract.java
index acb4fe8..eb71a37 100644
--- a/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreDAOContract.java
+++ b/server/blob/blob-api/src/test/java/org/apache/james/blob/api/BucketBlobStoreDAOContract.java
@@ -35,6 +35,7 @@
 import org.apache.james.util.concurrency.ConcurrentTestRunner;
 import org.junit.jupiter.api.Test;
 
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 public interface BucketBlobStoreDAOContract {
@@ -172,4 +173,68 @@
             .operationCount(10)
             .runSuccessfullyWithin(Duration.ofMinutes(1));
     }
+
+    @Test
+    default void listBucketsShouldReturnEmptyWhenNone() {
+        BlobStoreDAO store = testee();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .isEmpty();
+    }
+
+    @Test
+    default void listBucketsShouldReturnBucketInUse() {
+        BlobStoreDAO store = testee();
+
+        Mono.from(store.save(TEST_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .containsOnly(TEST_BUCKET_NAME);
+    }
+
+    @Test
+    default void listBucketsShouldNotReturnDuplicates() {
+        BlobStoreDAO store = testee();
+
+        Mono.from(store.save(TEST_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+        Mono.from(store.save(TEST_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .hasSize(1);
+    }
+
+    @Test
+    default void listBucketsShouldReturnAllBucketsInUse() {
+        BlobStoreDAO store = testee();
+
+        Mono.from(store.save(TEST_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+        Mono.from(store.save(CUSTOM_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .containsOnly(TEST_BUCKET_NAME, CUSTOM_BUCKET_NAME);
+    }
+
+    @Test
+    default void listBucketsShouldNotReturnDeletedBuckets() {
+        BlobStoreDAO store = testee();
+
+        Mono.from(store.save(TEST_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+
+        Mono.from(store.deleteBucket(TEST_BUCKET_NAME)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .isEmpty();
+    }
+
+    @Test
+    default void listBucketsShouldReturnBucketsWithNoBlob() {
+        BlobStoreDAO store = testee();
+
+        Mono.from(store.save(TEST_BUCKET_NAME, TEST_BLOB_ID, SHORT_BYTEARRAY)).block();
+
+        Mono.from(store.delete(TEST_BUCKET_NAME, TEST_BLOB_ID)).block();
+
+        assertThat(Flux.from(store.listBuckets()).collectList().block())
+            .containsOnly(TEST_BUCKET_NAME);
+    }
 }
diff --git a/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAO.java b/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAO.java
index ffcc179..b27ec29 100644
--- a/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAO.java
+++ b/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAO.java
@@ -42,6 +42,7 @@
 import org.apache.james.metrics.api.MetricFactory;
 import org.apache.james.util.DataChunker;
 import org.apache.james.util.ReactorUtils;
+import org.reactivestreams.Publisher;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -69,7 +70,6 @@
     private final CassandraConfiguration configuration;
     private final BucketName defaultBucket;
 
-    private final MetricFactory metricFactory;
     private final Metric metricClOneHitCount;
     private final Metric metricClOneMissCount;
 
@@ -84,7 +84,6 @@
         this.bucketDAO = bucketDAO;
         this.configuration = cassandraConfiguration;
         this.defaultBucket = defaultBucket;
-        this.metricFactory = metricFactory;
 
         this.metricClOneMissCount = metricFactory.generate(CASSANDRA_BLOBSTORE_CL_ONE_MISS_COUNT_METRIC_NAME);
         this.metricClOneHitCount = metricFactory.generate(CASSANDRA_BLOBSTORE_CL_ONE_HIT_COUNT_METRIC_NAME);
@@ -269,4 +268,11 @@
             .reduce(ByteBuffer.allocate(targetSize), ByteBuffer::put)
             .array();
     }
+
+    @Override
+    public Publisher<BucketName> listBuckets() {
+        return bucketDAO.listAll()
+            .map(Pair::getLeft)
+            .distinct();
+    }
 }
diff --git a/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/cache/CachedBlobStore.java b/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/cache/CachedBlobStore.java
index 9b6d0b9..fb74113 100644
--- a/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/cache/CachedBlobStore.java
+++ b/server/blob/blob-cassandra/src/main/java/org/apache/james/blob/cassandra/cache/CachedBlobStore.java
@@ -322,4 +322,9 @@
         return Mono.from(metricFactory.decoratePublisherWithTimerMetric(BLOBSTORE_BACKEND_LATENCY_METRIC_NAME,
             backend.readBytes(bucketName, blobId)));
     }
+
+    @Override
+    public Publisher<BucketName> listBuckets() {
+        return backend.listBuckets();
+    }
 }
diff --git a/server/blob/blob-cassandra/src/test/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAOTest.java b/server/blob/blob-cassandra/src/test/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAOTest.java
index 6b2ad1d..159d5a1 100644
--- a/server/blob/blob-cassandra/src/test/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAOTest.java
+++ b/server/blob/blob-cassandra/src/test/java/org/apache/james/blob/cassandra/CassandraBlobStoreDAOTest.java
@@ -28,11 +28,11 @@
 import org.apache.james.blob.api.HashBlobId;
 import org.apache.james.metrics.tests.RecordingMetricFactory;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
 public class CassandraBlobStoreDAOTest implements BlobStoreDAOContract {
     private static final int CHUNK_SIZE = 10240;
-    private static final int MULTIPLE_CHUNK_SIZE = 3;
 
     @RegisterExtension
     static CassandraClusterExtension cassandraCluster = new CassandraClusterExtension(CassandraBlobModule.MODULE);
@@ -60,4 +60,9 @@
         return testee;
     }
 
+    @Override
+    @Disabled("Not supported by the Cassandra blob store")
+    public void listBucketsShouldReturnBucketsWithNoBlob() {
+
+    }
 }
\ No newline at end of file
diff --git a/server/blob/blob-memory/src/main/java/org/apache/james/blob/memory/MemoryBlobStoreDAO.java b/server/blob/blob-memory/src/main/java/org/apache/james/blob/memory/MemoryBlobStoreDAO.java
index f66cc1f..95b256f 100644
--- a/server/blob/blob-memory/src/main/java/org/apache/james/blob/memory/MemoryBlobStoreDAO.java
+++ b/server/blob/blob-memory/src/main/java/org/apache/james/blob/memory/MemoryBlobStoreDAO.java
@@ -29,12 +29,14 @@
 import org.apache.james.blob.api.BucketName;
 import org.apache.james.blob.api.ObjectNotFoundException;
 import org.apache.james.blob.api.ObjectStoreIOException;
+import org.reactivestreams.Publisher;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
 import com.google.common.io.ByteSource;
 
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 public class MemoryBlobStoreDAO implements BlobStoreDAO {
@@ -110,4 +112,9 @@
             }
         });
     }
+
+    @Override
+    public Publisher<BucketName> listBuckets() {
+        return Flux.fromIterable(blobs.rowKeySet());
+    }
 }
diff --git a/server/blob/blob-memory/src/test/java/org/apache/james/blob/memory/MemoryBlobStoreDAOTest.java b/server/blob/blob-memory/src/test/java/org/apache/james/blob/memory/MemoryBlobStoreDAOTest.java
index 3225301..83748a7 100644
--- a/server/blob/blob-memory/src/test/java/org/apache/james/blob/memory/MemoryBlobStoreDAOTest.java
+++ b/server/blob/blob-memory/src/test/java/org/apache/james/blob/memory/MemoryBlobStoreDAOTest.java
@@ -22,6 +22,7 @@
 import org.apache.james.blob.api.BlobStoreDAO;
 import org.apache.james.blob.api.BlobStoreDAOContract;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 
 class MemoryBlobStoreDAOTest implements BlobStoreDAOContract {
 
@@ -36,4 +37,10 @@
     public BlobStoreDAO testee() {
         return blobStore;
     }
+
+    @Override
+    @Disabled("Not supported")
+    public void listBucketsShouldReturnBucketsWithNoBlob() {
+
+    }
 }
diff --git a/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/BucketNameResolver.java b/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/BucketNameResolver.java
index a0fcb71..7b13ad3 100644
--- a/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/BucketNameResolver.java
+++ b/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/BucketNameResolver.java
@@ -95,6 +95,19 @@
             .orElse(bucketName);
     }
 
+    Optional<BucketName> unresolve(BucketName bucketName) {
+        if (isNameSpace(bucketName)) {
+            return Optional.of(bucketName);
+        }
+
+        return prefix.map(p -> {
+            if (bucketName.asString().startsWith(p)) {
+                return Optional.of(BucketName.of(bucketName.asString().substring(p.length())));
+            }
+            return Optional.<BucketName>empty();
+        }).orElse(Optional.of(bucketName));
+    }
+
     private boolean isNameSpace(BucketName bucketName) {
         return namespace
             .map(existingNamespace -> existingNamespace.equals(bucketName))
diff --git a/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAO.java b/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAO.java
index 5849bf9..24a35a4 100644
--- a/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAO.java
+++ b/server/blob/blob-s3/src/main/java/org/apache/james/blob/objectstorage/aws/S3BlobStoreDAO.java
@@ -41,6 +41,7 @@
 import org.apache.james.lifecycle.api.Startable;
 import org.apache.james.util.DataChunker;
 import org.apache.james.util.ReactorUtils;
+import org.reactivestreams.Publisher;
 
 import com.github.fge.lambdas.Throwing;
 import com.github.steveash.guavate.Guavate;
@@ -63,11 +64,11 @@
 import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
 import software.amazon.awssdk.services.s3.S3AsyncClient;
 import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.model.Bucket;
 import software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException;
 import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
 import software.amazon.awssdk.services.s3.model.GetObjectResponse;
 import software.amazon.awssdk.services.s3.model.ListBucketsResponse;
-import software.amazon.awssdk.services.s3.model.ListObjectsResponse;
 import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
 import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
 import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
@@ -279,10 +280,13 @@
 
     private Mono<BucketName> emptyBucket(BucketName bucketName) {
         return Mono.fromFuture(() -> client.listObjects(builder -> builder.bucket(bucketName.asString())))
-            .flatMapIterable(ListObjectsResponse::contents)
-            .window(EMPTY_BUCKET_BATCH_SIZE)
-            .flatMap(this::buildListForBatch, DEFAULT_CONCURRENCY)
-            .flatMap(identifiers -> deleteObjects(bucketName, identifiers), DEFAULT_CONCURRENCY)
+            .flatMap(response -> Flux.fromIterable(response.contents())
+                .window(EMPTY_BUCKET_BATCH_SIZE)
+                .flatMap(this::buildListForBatch, DEFAULT_CONCURRENCY)
+                .flatMap(identifiers -> deleteObjects(bucketName, identifiers), DEFAULT_CONCURRENCY)
+                .then(Mono.just(response)))
+            .flux()
+            .takeUntil(list -> !list.isTruncated())
             .then(Mono.just(bucketName));
     }
 
@@ -305,4 +309,13 @@
                 .flatMap(bucket -> deleteResolvedBucket(BucketName.of(bucket.name())), DEFAULT_CONCURRENCY)
             .then();
     }
+
+    @Override
+    public Publisher<BucketName> listBuckets() {
+        return Mono.fromFuture(client::listBuckets)
+            .flatMapIterable(ListBucketsResponse::buckets)
+            .map(Bucket::name)
+            .handle((bucket, sink) -> bucketNameResolver.unresolve(BucketName.of(bucket))
+                .ifPresent(sink::next));
+    }
 }
diff --git a/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/BucketNameResolverTest.java b/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/BucketNameResolverTest.java
index 43e9e84..178830d 100644
--- a/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/BucketNameResolverTest.java
+++ b/server/blob/blob-s3/src/test/java/org/apache/james/blob/objectstorage/aws/BucketNameResolverTest.java
@@ -25,10 +25,25 @@
 import org.apache.james.blob.api.BucketName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 class BucketNameResolverTest {
     @Nested
     class EmptyPrefix {
+        @ParameterizedTest
+        @ValueSource(strings = {"namespace", "any", "bucketPrefix-aaa", "bucketPrefix-"})
+        void withShouldAddNewValuesInSet(String bucketNameString) {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .noPrefix()
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            BucketName bucketName = BucketName.of(bucketNameString);
+            assertThat(resolver.unresolve(resolver.resolve(bucketName)))
+                .contains(bucketName);
+        }
+
         @Test
         void resolveShouldReturnPassedValue() {
             BucketNameResolver resolver = BucketNameResolver.builder()
@@ -50,10 +65,45 @@
             assertThat(resolver.resolve(BucketName.of("namespace")))
                 .isEqualTo(BucketName.of("namespace"));
         }
+
+        @Test
+        void unresolveShouldReturnPassedValue() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .noPrefix()
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("bucketName")))
+                .contains(BucketName.of("bucketName"));
+        }
+
+        @Test
+        void unresolveShouldReturnValueWhenNamespace() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .noPrefix()
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("namespace")))
+                .contains(BucketName.of("namespace"));
+        }
     }
 
     @Nested
     class EmptyNamespace {
+        @ParameterizedTest
+        @ValueSource(strings = {"namespace", "any", "bucketPrefix-aaa", "bucketPrefix-"})
+        void withShouldAddNewValuesInSet(String bucketNameString) {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .noNamespace()
+                .build();
+
+            BucketName bucketName = BucketName.of(bucketNameString);
+            assertThat(resolver.unresolve(resolver.resolve(bucketName)))
+                .contains(bucketName);
+        }
+
         @Test
         void resolveShouldReturnPassedValueWithPrefix() {
             BucketNameResolver resolver = BucketNameResolver.builder()
@@ -64,10 +114,45 @@
             assertThat(resolver.resolve(BucketName.of("bucketName")))
                 .isEqualTo(BucketName.of("bucketPrefix-bucketName"));
         }
+
+        @Test
+        void unresolveShouldReturnPassedValueWithPrefix() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .noNamespace()
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("bucketPrefix-bucketName")))
+                .contains(BucketName.of("bucketName"));
+        }
+
+        @Test
+        void unresolveShouldFilterValuesWithoutPrefix() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .noNamespace()
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("bucketName")))
+                .isEmpty();
+        }
     }
 
     @Nested
     class BothAreEmpty {
+        @ParameterizedTest
+        @ValueSource(strings = {"namespace", "any", "bucketPrefix-aaa", "bucketPrefix-"})
+        void withShouldAddNewValuesInSet(String bucketNameString) {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .noPrefix()
+                .noNamespace()
+                .build();
+
+            BucketName bucketName = BucketName.of(bucketNameString);
+            assertThat(resolver.unresolve(resolver.resolve(bucketName)))
+                .contains(bucketName);
+        }
+
         @Test
         void resolveShouldReturnPassedValue() {
             BucketNameResolver resolver = BucketNameResolver.builder()
@@ -78,10 +163,35 @@
             assertThat(resolver.resolve(BucketName.of("bucketName")))
                 .isEqualTo(BucketName.of("bucketName"));
         }
+
+        @Test
+        void unresolveShouldReturnPassedValue() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .noPrefix()
+                .noNamespace()
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("bucketName")))
+                .contains(BucketName.of("bucketName"));
+        }
     }
 
     @Nested
     class BothArePresent {
+
+        @ParameterizedTest
+        @ValueSource(strings = {"namespace", "any", "bucketPrefix-aaa", "bucketPrefix-"})
+        void withShouldAddNewValuesInSet(String bucketNameString) {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            BucketName bucketName = BucketName.of(bucketNameString);
+            assertThat(resolver.unresolve(resolver.resolve(bucketName)))
+                .contains(bucketName);
+        }
+
         @Test
         void resolveShouldReturnPassedValueWithPrefix() {
             BucketNameResolver resolver = BucketNameResolver.builder()
@@ -103,6 +213,39 @@
             assertThat(resolver.resolve(BucketName.of("namespace")))
                 .isEqualTo(BucketName.of("namespace"));
         }
+
+        @Test
+        void unresolveShouldFilterValuesWithoutPrefix() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("bucketName")))
+                .isEmpty();
+        }
+
+        @Test
+        void unresolveShouldRemovePrefix() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("bucketPrefix-bucketName")))
+                .contains(BucketName.of("bucketName"));
+        }
+
+        @Test
+        void unresolveShouldReturnNamespaceWhenPassingNamespace() {
+            BucketNameResolver resolver = BucketNameResolver.builder()
+                .prefix("bucketPrefix-")
+                .namespace(BucketName.of("namespace"))
+                .build();
+
+            assertThat(resolver.unresolve(BucketName.of("namespace")))
+                .contains(BucketName.of("namespace"));
+        }
     }
 
 
diff --git a/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/DeDuplicationBlobStore.scala b/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/DeDuplicationBlobStore.scala
index 618beb4..15148c2 100644
--- a/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/DeDuplicationBlobStore.scala
+++ b/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/DeDuplicationBlobStore.scala
@@ -29,7 +29,7 @@
 import org.apache.commons.io.IOUtils
 import org.apache.james.blob.api.{BlobId, BlobStore, BlobStoreDAO, BucketName}
 import org.reactivestreams.Publisher
-import reactor.core.publisher.Mono
+import reactor.core.publisher.{Flux, Mono}
 import reactor.core.scala.publisher.SMono
 import reactor.util.function.{Tuple2, Tuples}
 
@@ -112,4 +112,6 @@
 
     SMono.just(Boolean.box(false))
   }
+
+  override def listBuckets(): Publisher[BucketName] = Flux.concat(blobStoreDAO.listBuckets(), Flux.just(defaultBucketName)).distinct()
 }
diff --git a/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/PassThroughBlobStore.scala b/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/PassThroughBlobStore.scala
index 6cb73e4..faf4a5d 100644
--- a/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/PassThroughBlobStore.scala
+++ b/server/blob/blob-storage-strategy/src/main/scala/org/apache/james/server/blob/deduplication/PassThroughBlobStore.scala
@@ -26,6 +26,7 @@
 import javax.inject.{Inject, Named}
 import org.apache.james.blob.api.{BlobId, BlobStore, BlobStoreDAO, BucketName}
 import org.reactivestreams.Publisher
+import reactor.core.publisher.Flux
 import reactor.core.scala.publisher.SMono
 
 
@@ -86,4 +87,6 @@
     SMono.fromPublisher(blobStoreDAO.delete(bucketName, blobId))
       .`then`(SMono.just(Boolean.box(true)))
   }
+
+  override def listBuckets(): Publisher[BucketName] = Flux.concat(blobStoreDAO.listBuckets(), Flux.just(defaultBucketName)).distinct()
 }