JAMES-3946 Add a DropLists postgresql backend (#2290)

diff --git a/examples/pom.xml b/examples/pom.xml
index 64df276..1d6a0e6 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -38,7 +38,7 @@
     <james.protocols.groupId>${james.groupId}.protocols</james.protocols.groupId>
     <maven.compiler.target>1.11</maven.compiler.target>
     <maven.compiler.source>1.11</maven.compiler.source>
-    <jacoco-maven-plugin.version>0.8.9</jacoco-maven-plugin.version>
+    <jacoco-maven-plugin.version>0.8.12</jacoco-maven-plugin.version>
   </properties>
 
   <modules>
diff --git a/pom.xml b/pom.xml
index e1d1fad..f2a9ee3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -659,7 +659,7 @@
         <scalatestplus-play.version>5.0.0</scalatestplus-play.version>
         <doclint>none</doclint>
         <mockito.version>5.10.0</mockito.version>
-        <jacoco-maven-plugin.version>0.8.9</jacoco-maven-plugin.version>
+        <jacoco-maven-plugin.version>0.8.12</jacoco-maven-plugin.version>
         <javacrumbs.json-unit.version>2.38.0</javacrumbs.json-unit.version>
     </properties>
 
diff --git a/server/apps/distributed-app/docs/modules/ROOT/nav.adoc b/server/apps/distributed-app/docs/modules/ROOT/nav.adoc
index bf98a74..60b767f 100644
--- a/server/apps/distributed-app/docs/modules/ROOT/nav.adoc
+++ b/server/apps/distributed-app/docs/modules/ROOT/nav.adoc
@@ -34,6 +34,7 @@
 **** xref:configure/batchsizes.adoc[batchsizes.properties]
 **** xref:configure/dns.adoc[dnsservice.xml]
 **** xref:configure/domainlist.adoc[domainlist.xml]
+**** xref:configure/droplists.adoc[DropLists]
 **** xref:configure/healthcheck.adoc[healthcheck.properties]
 **** xref:configure/mailetcontainer.adoc[mailetcontainer.xml]
 **** xref:configure/mailets.adoc[Packaged Mailets]
diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/droplists.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/droplists.adoc
new file mode 100644
index 0000000..e63a846
--- /dev/null
+++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/droplists.adoc
@@ -0,0 +1,32 @@
+= Distributed James Server &mdash; DropLists
+:navtitle: DropLists
+
+The DropList, also known as the mail blacklist, is a collection of
+domains and email addresses that are denied from sending emails within the system.
+It is disabled by default.
+To enable it, modify the `droplists.properties` file and include the `IsInDropList` matcher in the `mailetcontainer.xml`.
+To disable it, adjust the `droplists.properties` file and remove the `IsInDropList` matcher from the `mailetcontainer.xml`.
+
+.droplists.properties content
+|===
+| Property name | explanation
+
+| enabled
+| Boolean. Governs whether DropLists should be enabled. Defaults to `false`.
+|===
+
+== Enabling Matcher
+
+Plug the `IsInDropList` matcher within `mailetcontainer.xml` :
+
+....
+<mailet match="org.apache.james.transport.matchers.IsInDropList" class="ToProcessor">
+    <processor>transport</processor>
+</mailet>
+....
+
+== DropList management
+
+DropList management, including adding and deleting entries, is performed through the WebAdmin REST API.
+
+See xref:operate/webadmin.adoc#_administrating_droplists[WebAdmin DropLists].
\ No newline at end of file
diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/index.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/index.adoc
index 2fa9d90..bc9b0f9 100644
--- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/index.adoc
+++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/index.adoc
@@ -66,6 +66,7 @@
 ** xref:configure/collecting-contacts.adoc[This page] documents contact collection
 ** xref:configure/collecting-events.adoc[This page] documents event collection
 ** xref:configure/dsn.adoc[this page] specified how to support SMTP Delivery Submission Notification (link:https://tools.ietf.org/html/rfc3461[RFC-3461])
+** xref:configure/droplists.adoc[This page] allows configuring drop lists.
 
 == System properties
 
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 81ab4f0..0186ff4 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
@@ -2835,6 +2835,126 @@
 
 * 204: Operation succeeded
 
+== Administrating DropLists
+
+The DropList, also known as the mail blacklist, is a collection of
+domains and email addresses that are denied from sending emails within the system.
+
+Owner scopes:
+
+- *global*: contains entries that are blocked across all domains and addresses within the system.
+Entries in the global owner scope apply universally and affect all users and domains.
+- *domain*: each domain can have its own droplist, which contains entries specific to that domain.
+- *user*: allow to customize personalized droplist of blocked domains and email addresses.
+
+The `deniedEntityType` query parameter is optional and can take the values `domain` or `address`.
+
+=== Getting the DropList
+==== Global DropList
+....
+curl -XGET http://ip:port/droplist/global?deniedEntityType=null|domain|address
+....
+==== Domain DropList
+....
+curl -XGET http://ip:port/droplist/domain/target.com?deniedEntityType=null|domain|address
+....
+==== User DropList
+....
+curl -XGET http://ip:port/droplist/user/tagret@target.com?deniedEntityType=null|domain|address
+....
+
+The answer looks like:
+....
+[ "evil.com", "devil.com", "bad_guy@crime.com", "hacker@murder.org" ]
+....
+
+Response codes:
+
+* 200: The drop list was successfully retrieved
+* 400: Invalid `owner scope` or `deniedEntityType`
+
+=== Testing a denied entity existence
+==== Global DropList
+....
+curl -XHEAD http://ip:port/droplist/global/attacker@evil.com
+....
+....
+curl -XHEAD http://ip:port/droplist/global/evil.com
+....
+==== Domain DropList
+....
+curl -XHEAD http://ip:port/droplist/domain/target.com/attacker@evil.com
+....
+....
+curl -XHEAD http://ip:port/droplist/domain/target.com/evil.com
+....
+==== User DropList
+....
+curl -XHEAD http://ip:port/droplist/user/target@target.com/attacker@evil.com
+....
+....
+curl -XHEAD http://ip:port/droplist/user/target@target.com/evil.com
+....
+Response codes:
+
+* 200: The denied entity exists
+* 404: The denied entity does not exist
+
+=== Add Entry to the DropList
+The denied entity must be a valid email address or link:#_create_a_domain[domain].
+
+==== Global DropList
+....
+curl -XPUT http://ip:port/droplist/global/attacker@evil.com
+....
+....
+curl -XPUT http://ip:port/droplist/global/evil.com
+....
+==== Domain DropList
+....
+curl -XPUT http://ip:port/droplist/domain/target.com/attacker@evil.com
+....
+....
+curl -XPUT http://ip:port/droplist/domain/target.com/evil.com
+....
+==== User DropList
+....
+curl -XPUT http://ip:port/droplist/user/target@target.com/attacker@evil.com
+....
+....
+curl -XPUT http://ip:port/droplist/user/target@target.com/evil.com
+....
+Response codes:
+
+* 204: The denied entity was successfully added
+* 400: The denied entity is invalid
+
+=== Remove Entry from the DropList
+==== Global DropList
+....
+curl -XDELETE http://ip:port/droplist/global/attacker@evil.com
+....
+....
+curl -XDELETE http://ip:port/droplist/global/evil.com
+....
+==== Domain DropList
+....
+curl -XDELETE http://ip:port/droplist/domain/target.com/attacker@evil.com
+....
+....
+curl -XDELETE http://ip:port/droplist/domain/target.com/evil.com
+....
+==== User DropList
+....
+curl -XDELETE http://ip:port/droplist/user/target@target.com/attacker@evil.com
+....
+....
+curl -XDELETE http://ip:port/droplist/user/target@target.com/evil.com
+....
+Response codes:
+
+* 204: Entry deleted successfully.
+
 == Administrating Jmap Uploads
 
 === Cleaning upload repository
diff --git a/server/apps/distributed-app/sample-configuration/droplists.properties b/server/apps/distributed-app/sample-configuration/droplists.properties
new file mode 100644
index 0000000..bbc2756
--- /dev/null
+++ b/server/apps/distributed-app/sample-configuration/droplists.properties
@@ -0,0 +1,3 @@
+# Configuration file for DropLists
+
+enabled=false
\ No newline at end of file
diff --git a/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesConfiguration.java b/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesConfiguration.java
index eb4bbce..c4b6436 100644
--- a/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesConfiguration.java
+++ b/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesConfiguration.java
@@ -52,6 +52,7 @@
         private Optional<VaultConfiguration> vaultConfiguration;
         private Optional<Boolean> jmapEnabled;
         private Optional<Boolean> quotaCompatibilityMode;
+        private Optional<Boolean> dropListsEnabled;
 
         private Builder() {
             searchConfiguration = Optional.empty();
@@ -64,6 +65,7 @@
             vaultConfiguration = Optional.empty();
             jmapEnabled = Optional.empty();
             quotaCompatibilityMode = Optional.empty();
+            dropListsEnabled = Optional.empty();
         }
 
         public Builder workingDirectory(String path) {
@@ -134,6 +136,11 @@
             return this;
         }
 
+        public Builder enableDropLists() {
+            this.dropListsEnabled = Optional.of(true);
+            return this;
+        }
+
         public CassandraRabbitMQJamesConfiguration build() {
             ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF));
             JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory
@@ -190,6 +197,16 @@
                 }
             });
 
+            boolean dropListsEnabled = this.dropListsEnabled.orElseGet(() -> {
+                try {
+                    return propertiesProvider.getConfiguration("droplists").getBoolean("enabled", false);
+                } catch (FileNotFoundException e) {
+                    return false;
+                } catch (ConfigurationException e) {
+                    throw new RuntimeException(e);
+                }
+            });
+
             return new CassandraRabbitMQJamesConfiguration(
                 configurationPath,
                 directories,
@@ -199,7 +216,8 @@
                 mailQueueChoice,
                 mailQueueViewChoice, vaultConfiguration,
                 jmapEnabled,
-                quotaCompatibilityMode);
+                quotaCompatibilityMode,
+                dropListsEnabled);
         }
     }
 
@@ -217,12 +235,13 @@
     private final VaultConfiguration vaultConfiguration;
     private final boolean jmapEnabled;
     private final boolean quotaCompatibilityMode;
+    private final boolean dropListsEnabled;
 
     public CassandraRabbitMQJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories,
                                                BlobStoreConfiguration blobStoreConfiguration, SearchConfiguration searchConfiguration,
                                                UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation, MailQueueChoice mailQueueChoice,
                                                MailQueueViewChoice mailQueueViewChoice, VaultConfiguration vaultConfiguration,
-                                               boolean jmapEnabled, boolean quotaCompatibilityMode) {
+                                               boolean jmapEnabled, boolean quotaCompatibilityMode, boolean dropListsEnabled) {
         this.configurationPath = configurationPath;
         this.directories = directories;
         this.blobStoreConfiguration = blobStoreConfiguration;
@@ -233,6 +252,7 @@
         this.vaultConfiguration = vaultConfiguration;
         this.jmapEnabled = jmapEnabled;
         this.quotaCompatibilityMode = quotaCompatibilityMode;
+        this.dropListsEnabled = dropListsEnabled;
     }
 
     public MailQueueViewChoice getMailQueueViewChoice() {
@@ -276,4 +296,8 @@
     public boolean isQuotaCompatibilityMode() {
         return quotaCompatibilityMode;
     }
+
+    public boolean isDropListsEnabled() {
+        return dropListsEnabled;
+    }
 }
diff --git a/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java b/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java
index aebbf32..e0c750f 100644
--- a/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java
+++ b/server/apps/distributed-app/src/main/java/org/apache/james/CassandraRabbitMQJamesServerMain.java
@@ -41,6 +41,7 @@
 import org.apache.james.modules.data.CassandraDLPConfigurationStoreModule;
 import org.apache.james.modules.data.CassandraDelegationStoreModule;
 import org.apache.james.modules.data.CassandraDomainListModule;
+import org.apache.james.modules.data.CassandraDropListsModule;
 import org.apache.james.modules.data.CassandraJmapModule;
 import org.apache.james.modules.data.CassandraRecipientRewriteTableModule;
 import org.apache.james.modules.data.CassandraSieveQuotaLegacyModule;
@@ -75,6 +76,7 @@
 import org.apache.james.modules.server.DKIMMailetModule;
 import org.apache.james.modules.server.DLPRoutesModule;
 import org.apache.james.modules.server.DataRoutesModules;
+import org.apache.james.modules.server.DropListsRoutesModule;
 import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule;
 import org.apache.james.modules.server.JMXServerModule;
 import org.apache.james.modules.server.JmapTasksModule;
@@ -209,7 +211,8 @@
                 .chooseModules(configuration.getUsersRepositoryImplementation()))
             .combineWith(chooseDeletedMessageVault(configuration.getVaultConfiguration()))
             .combineWith(chooseQuotaModule(configuration))
-            .overrideWith(chooseJmapModules(configuration));
+            .overrideWith(chooseJmapModules(configuration))
+            .overrideWith(chooseDropListsModule(configuration));
     }
 
     private static Module chooseMailQueue(CassandraRabbitMQJamesConfiguration configuration) {
@@ -258,4 +261,14 @@
             return Modules.combine(new CassandraMailboxQuotaModule(), new CassandraSieveQuotaModule());
         }
     }
+
+    private static Module chooseDropListsModule(CassandraRabbitMQJamesConfiguration configuration) {
+        if (configuration.isDropListsEnabled()) {
+            return Modules.combine(new CassandraDropListsModule(), new DropListsRoutesModule());
+        }
+        return binder -> {
+
+        };
+    }
+
 }
diff --git a/server/apps/memory-app/sample-configuration/droplists.properties b/server/apps/memory-app/sample-configuration/droplists.properties
new file mode 100644
index 0000000..bbc2756
--- /dev/null
+++ b/server/apps/memory-app/sample-configuration/droplists.properties
@@ -0,0 +1,3 @@
+# Configuration file for DropLists
+
+enabled=false
\ No newline at end of file
diff --git a/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesConfiguration.java b/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesConfiguration.java
index 88bfff3..aa0e08f 100644
--- a/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesConfiguration.java
+++ b/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesConfiguration.java
@@ -41,12 +41,14 @@
         private Optional<ConfigurationPath> configurationPath;
         private Optional<UsersRepositoryModuleChooser.Implementation> usersRepositoryImplementation;
         private Optional<Boolean> jmapEnabled;
+        private Optional<Boolean> dropListsEnabled;
 
         private Builder() {
             rootDirectory = Optional.empty();
             configurationPath = Optional.empty();
             usersRepositoryImplementation = Optional.empty();
             jmapEnabled = Optional.empty();
+            dropListsEnabled = Optional.empty();
         }
 
         public Builder workingDirectory(String path) {
@@ -87,6 +89,11 @@
             return this;
         }
 
+        public Builder enableDropLists() {
+            this.dropListsEnabled = Optional.of(true);
+            return this;
+        }
+
         public MemoryJamesConfiguration build() {
             ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF));
             JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory
@@ -112,10 +119,22 @@
                 }
             });
 
+            boolean dropListsEnabled = this.dropListsEnabled.orElseGet(() -> {
+                PropertiesProvider propertiesProvider = new PropertiesProvider(fileSystem, configurationPath);
+                try {
+                    return propertiesProvider.getConfiguration("droplists").getBoolean("enabled", false);
+                } catch (FileNotFoundException e) {
+                    return false;
+                } catch (ConfigurationException e) {
+                    throw new RuntimeException(e);
+                }
+            });
+
+
             return new MemoryJamesConfiguration(
                 configurationPath,
                 directories,
-                usersRepositoryChoice, jmapEnabled);
+                usersRepositoryChoice, jmapEnabled, dropListsEnabled);
         }
     }
 
@@ -127,12 +146,16 @@
     private final JamesDirectoriesProvider directories;
     private final UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation;
     private final boolean jmapEnabled;
+    private final boolean dropListsEnabled;
 
-    public MemoryJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation, boolean jmapEnabled) {
+    public MemoryJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories,
+                                    UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation,
+                                    boolean jmapEnabled, boolean dropListsEnabled) {
         this.configurationPath = configurationPath;
         this.directories = directories;
         this.usersRepositoryImplementation = usersRepositoryImplementation;
         this.jmapEnabled = jmapEnabled;
+        this.dropListsEnabled = dropListsEnabled;
     }
 
     @Override
@@ -152,4 +175,8 @@
     public boolean isJmapEnabled() {
         return jmapEnabled;
     }
+
+    public boolean isDropListsEnabled() {
+        return dropListsEnabled;
+    }
 }
diff --git a/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java b/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java
index 959c3fe..f1797b5 100644
--- a/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java
+++ b/server/apps/memory-app/src/main/java/org/apache/james/MemoryJamesServerMain.java
@@ -34,6 +34,7 @@
 import org.apache.james.modules.data.MemoryDataJmapModule;
 import org.apache.james.modules.data.MemoryDataModule;
 import org.apache.james.modules.data.MemoryDelegationStoreModule;
+import org.apache.james.modules.data.MemoryDropListsModule;
 import org.apache.james.modules.data.MemoryUsersRepositoryModule;
 import org.apache.james.modules.eventstore.MemoryEventStoreModule;
 import org.apache.james.modules.mailbox.MemoryMailboxModule;
@@ -49,6 +50,7 @@
 import org.apache.james.modules.server.DKIMMailetModule;
 import org.apache.james.modules.server.DLPRoutesModule;
 import org.apache.james.modules.server.DataRoutesModules;
+import org.apache.james.modules.server.DropListsRoutesModule;
 import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule;
 import org.apache.james.modules.server.JMXServerModule;
 import org.apache.james.modules.server.JmapTasksModule;
@@ -174,7 +176,8 @@
             .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
             .combineWith(new UsersRepositoryModuleChooser(new MemoryUsersRepositoryModule())
                 .chooseModules(configuration.getUsersRepositoryImplementation()))
-            .combineWith(chooseJmapModule(configuration));
+            .combineWith(chooseJmapModule(configuration))
+            .combineWith(chooseDropListsModule(configuration));
     }
 
     private static Module chooseJmapModule(MemoryJamesConfiguration configuration) {
@@ -186,4 +189,13 @@
         };
     }
 
+    private static Module chooseDropListsModule(MemoryJamesConfiguration configuration) {
+        if (configuration.isDropListsEnabled()) {
+            return Modules.combine(new MemoryDropListsModule(), new DropListsRoutesModule());
+        }
+        return binder -> {
+
+        };
+    }
+
 }
diff --git a/server/apps/postgres-app/sample-configuration/droplists.properties b/server/apps/postgres-app/sample-configuration/droplists.properties
new file mode 100644
index 0000000..bbc2756
--- /dev/null
+++ b/server/apps/postgres-app/sample-configuration/droplists.properties
@@ -0,0 +1,3 @@
+# Configuration file for DropLists
+
+enabled=false
\ No newline at end of file
diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java
index c4af2fd..4bf98c5 100644
--- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java
+++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java
@@ -74,6 +74,7 @@
         private Optional<EventBusImpl> eventBusImpl;
         private Optional<VaultConfiguration> deletedMessageVaultConfiguration;
         private Optional<Boolean> jmapEnabled;
+        private Optional<Boolean> dropListsEnabled;
 
         private Builder() {
             searchConfiguration = Optional.empty();
@@ -84,6 +85,7 @@
             eventBusImpl = Optional.empty();
             deletedMessageVaultConfiguration = Optional.empty();
             jmapEnabled = Optional.empty();
+            dropListsEnabled = Optional.empty();
         }
 
         public Builder workingDirectory(String path) {
@@ -144,6 +146,11 @@
             return this;
         }
 
+        public Builder enableDropLists() {
+            this.dropListsEnabled = Optional.of(true);
+            return this;
+        }
+
         public PostgresJamesConfiguration build() {
             ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF));
             JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory
@@ -189,6 +196,14 @@
                 }
             });
 
+            boolean dropListsEnabled = this.dropListsEnabled.orElseGet(() -> {
+                try {
+                    return configurationProvider.getConfiguration("droplists").getBoolean("enabled", false);
+                } catch (ConfigurationException e) {
+                    return false;
+                }
+            });
+
             LOGGER.info("BlobStore configuration {}", blobStoreConfiguration);
             return new PostgresJamesConfiguration(
                 configurationPath,
@@ -198,7 +213,8 @@
                 blobStoreConfiguration,
                 eventBusImpl,
                 deletedMessageVaultConfiguration,
-                jmapEnabled);
+                jmapEnabled,
+                dropListsEnabled);
         }
     }
 
@@ -214,6 +230,7 @@
     private final EventBusImpl eventBusImpl;
     private final VaultConfiguration deletedMessageVaultConfiguration;
     private final boolean jmapEnabled;
+    private final boolean dropListsEnabled;
 
     private PostgresJamesConfiguration(ConfigurationPath configurationPath,
                                        JamesDirectoriesProvider directories,
@@ -222,7 +239,8 @@
                                        BlobStoreConfiguration blobStoreConfiguration,
                                        EventBusImpl eventBusImpl,
                                        VaultConfiguration deletedMessageVaultConfiguration,
-                                       boolean jmapEnabled) {
+                                       boolean jmapEnabled,
+                                       boolean dropListsEnabled) {
         this.configurationPath = configurationPath;
         this.directories = directories;
         this.searchConfiguration = searchConfiguration;
@@ -231,6 +249,7 @@
         this.eventBusImpl = eventBusImpl;
         this.deletedMessageVaultConfiguration = deletedMessageVaultConfiguration;
         this.jmapEnabled = jmapEnabled;
+        this.dropListsEnabled = dropListsEnabled;
     }
 
     @Override
@@ -266,4 +285,8 @@
     public boolean isJmapEnabled() {
         return jmapEnabled;
     }
+
+    public boolean isDropListsEnabled() {
+        return dropListsEnabled;
+    }
 }
diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java
index cbd4819..774d687 100644
--- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java
+++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java
@@ -40,6 +40,7 @@
 import org.apache.james.modules.data.PostgresDataJmapModule;
 import org.apache.james.modules.data.PostgresDataModule;
 import org.apache.james.modules.data.PostgresDelegationStoreModule;
+import org.apache.james.modules.data.PostgresDropListsModule;
 import org.apache.james.modules.data.PostgresEventStoreModule;
 import org.apache.james.modules.data.PostgresUsersRepositoryModule;
 import org.apache.james.modules.data.PostgresVacationModule;
@@ -67,6 +68,7 @@
 import org.apache.james.modules.server.DKIMMailetModule;
 import org.apache.james.modules.server.DLPRoutesModule;
 import org.apache.james.modules.server.DataRoutesModules;
+import org.apache.james.modules.server.DropListsRoutesModule;
 import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule;
 import org.apache.james.modules.server.JMXServerModule;
 import org.apache.james.modules.server.JmapTasksModule;
@@ -98,7 +100,8 @@
 public class PostgresJamesServerMain implements JamesServerMain {
 
     private static final Module EVENT_STORE_JSON_SERIALIZATION_DEFAULT_MODULE = binder ->
-        binder.bind(new TypeLiteral<Set<DTOModule<?, ? extends DTO>>>() {}).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME))
+        binder.bind(new TypeLiteral<Set<DTOModule<?, ? extends DTO>>>() {
+            }).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME))
             .toInstance(ImmutableSet.of());
 
     private static final Module WEBADMIN = Modules.combine(
@@ -185,7 +188,8 @@
             .combineWith(chooseBlobStoreModules(configuration))
             .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration()))
             .overrideWith(chooseJmapModules(configuration))
-            .overrideWith(chooseTaskManagerModules(configuration));
+            .overrideWith(chooseTaskManagerModules(configuration))
+            .overrideWith(chooseDropListsModule(configuration));
     }
 
     private static List<Module> chooseUsersRepositoryModule(PostgresJamesConfiguration configuration) {
@@ -247,4 +251,13 @@
         return binder -> {
         };
     }
+
+    private static Module chooseDropListsModule(PostgresJamesConfiguration configuration) {
+        if (configuration.isDropListsEnabled()) {
+            return Modules.combine(new PostgresDropListsModule(), new DropListsRoutesModule());
+        }
+        return binder -> {
+
+        };
+    }
 }
diff --git a/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraDropListsModule.java b/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraDropListsModule.java
new file mode 100644
index 0000000..450cc85
--- /dev/null
+++ b/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraDropListsModule.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * 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.modules.data;
+
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.cassandra.CassandraDropList;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+
+public class CassandraDropListsModule extends AbstractModule {
+    @Override
+    protected void configure() {
+        bind(DropList.class).to(CassandraDropList.class).in(Scopes.SINGLETON);
+    }
+}
\ No newline at end of file
diff --git a/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDropListsModule.java b/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDropListsModule.java
new file mode 100644
index 0000000..7810e73
--- /dev/null
+++ b/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDropListsModule.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * 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.modules.data;
+
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.memory.MemoryDropList;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+
+public class MemoryDropListsModule extends AbstractModule {
+    @Override
+    public void configure() {
+        bind(DropList.class).to(MemoryDropList.class).in(Scopes.SINGLETON);
+    }
+}
\ No newline at end of file
diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java
new file mode 100644
index 0000000..d2f4397
--- /dev/null
+++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * 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.modules.data;
+
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.postgres.PostgresDropList;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+
+public class PostgresDropListsModule extends AbstractModule {
+    @Override
+    protected void configure() {
+        bind(DropList.class).to(PostgresDropList.class).in(Scopes.SINGLETON);
+    }
+}
diff --git a/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DropListsRoutesModule.java b/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DropListsRoutesModule.java
new file mode 100644
index 0000000..5a06422
--- /dev/null
+++ b/server/container/guice/protocols/webadmin-data/src/main/java/org/apache/james/modules/server/DropListsRoutesModule.java
@@ -0,0 +1,34 @@
+/****************************************************************
+ * 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.modules.server;
+
+import org.apache.james.webadmin.Routes;
+import org.apache.james.webadmin.routes.DropListRoutes;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
+
+public class DropListsRoutesModule extends AbstractModule {
+    @Override
+    protected void configure() {
+        Multibinder.newSetBinder(binder(), Routes.class).addBinding()
+            .to(DropListRoutes.class);
+    }
+}
diff --git a/server/data/data-api/src/main/java/org/apache/james/droplists/api/DeniedEntityType.java b/server/data/data-api/src/main/java/org/apache/james/droplists/api/DeniedEntityType.java
new file mode 100644
index 0000000..ae14928
--- /dev/null
+++ b/server/data/data-api/src/main/java/org/apache/james/droplists/api/DeniedEntityType.java
@@ -0,0 +1,33 @@
+/****************************************************************
+ * 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.droplists.api;
+
+/**
+ * Enum representing the types of entities that can be denied when managing email droplists.
+ */
+public enum DeniedEntityType {
+    /**
+     * Represents denying an address entity.
+     */
+    ADDRESS,
+    /**
+     * Represents denying a domain entity.
+     */
+    DOMAIN
+}
diff --git a/server/data/data-api/src/main/java/org/apache/james/droplists/api/DropList.java b/server/data/data-api/src/main/java/org/apache/james/droplists/api/DropList.java
new file mode 100644
index 0000000..386e958
--- /dev/null
+++ b/server/data/data-api/src/main/java/org/apache/james/droplists/api/DropList.java
@@ -0,0 +1,70 @@
+/****************************************************************
+ * 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.droplists.api;
+
+import org.apache.james.core.MailAddress;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * Interface for managing email droplists.
+ */
+public interface DropList {
+
+    /**
+     * Add an entry to the droplist.
+     *
+     * @param entry The entry to add.
+     * @return A Mono representing the completion of the operation.
+     */
+    Mono<Void> add(DropListEntry entry);
+
+    /**
+     * Remove an entry from the droplist.
+     *
+     * @param entry The entry to remove.
+     * @return A Mono representing the completion of the operation.
+     */
+    Mono<Void> remove(DropListEntry entry);
+
+    /**
+     * List all entries in the droplist for a specific owner.
+     *
+     * @param ownerScope The scope of the owner.
+     * @param owner      The owner for which to list the entries.
+     * @return A Flux emitting each entry in the droplist.
+     */
+    Flux<DropListEntry> list(OwnerScope ownerScope, String owner);
+
+    enum Status {
+        ALLOWED,
+        BLOCKED
+    }
+
+    /**
+     * Query the status of a sender's email address in the droplist.
+     *
+     * @param ownerScope The scope of the owner.
+     * @param owner      The owner for which to query the status.
+     * @param sender     The email address of the sender.
+     * @return A Mono emitting the status of the sender's email address (ALLOWED or BLOCKED).
+     */
+    Mono<Status> query(OwnerScope ownerScope, String owner, MailAddress sender);
+}
diff --git a/server/data/data-api/src/main/java/org/apache/james/droplists/api/DropListEntry.java b/server/data/data-api/src/main/java/org/apache/james/droplists/api/DropListEntry.java
new file mode 100644
index 0000000..7bec404
--- /dev/null
+++ b/server/data/data-api/src/main/java/org/apache/james/droplists/api/DropListEntry.java
@@ -0,0 +1,140 @@
+/****************************************************************
+ * 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.droplists.api;
+
+import static org.apache.james.droplists.api.OwnerScope.DOMAIN;
+import static org.apache.james.droplists.api.OwnerScope.GLOBAL;
+import static org.apache.james.droplists.api.OwnerScope.USER;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Preconditions;
+
+public class DropListEntry {
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public static class Builder {
+        private OwnerScope ownerScope;
+        private Optional<String> owner = Optional.empty();
+        private DeniedEntityType deniedEntityType;
+        private String deniedEntity;
+
+        public Builder userOwner(MailAddress mailAddress) {
+            Preconditions.checkNotNull(mailAddress);
+            this.owner = Optional.of(mailAddress.toString());
+            this.ownerScope = USER;
+            return this;
+        }
+
+        public Builder domainOwner(Domain domain) {
+            Preconditions.checkNotNull(domain);
+            this.owner = Optional.of(domain.asString());
+            this.ownerScope = DOMAIN;
+            return this;
+        }
+
+        public Builder forAll() {
+            this.ownerScope = GLOBAL;
+            return this;
+        }
+
+        public Builder denyDomain(Domain domain) {
+            Preconditions.checkNotNull(domain);
+            this.deniedEntity = domain.asString();
+            this.deniedEntityType = DeniedEntityType.DOMAIN;
+            return this;
+        }
+
+        public Builder denyAddress(MailAddress mailAddress) {
+            Preconditions.checkNotNull(mailAddress);
+            this.deniedEntity = mailAddress.toString();
+            this.deniedEntityType = DeniedEntityType.ADDRESS;
+            return this;
+        }
+
+        public DropListEntry build() {
+            Preconditions.checkArgument(deniedEntityType != null, "`deniedEntityType` is mandatory");
+            Preconditions.checkArgument(ownerScope != null, "`ownerScope` is mandatory");
+            Preconditions.checkArgument(deniedEntity != null && !deniedEntity.isBlank(), "`deniedEntity` must not be null, empty, or blank");
+            return new DropListEntry(ownerScope, owner, deniedEntityType, deniedEntity);
+        }
+    }
+
+    private final OwnerScope ownerScope;
+    private final Optional<String> owner;
+    private final DeniedEntityType deniedEntityType;
+    private final String deniedEntity;
+
+    private DropListEntry(OwnerScope ownerScope, Optional<String> owner, DeniedEntityType deniedEntityType, String deniedEntity) {
+        this.ownerScope = ownerScope;
+        this.owner = owner;
+        this.deniedEntityType = deniedEntityType;
+        this.deniedEntity = deniedEntity;
+    }
+
+    public OwnerScope getOwnerScope() {
+        return ownerScope;
+    }
+
+    public String getOwner() {
+        return owner.orElse("");
+    }
+
+    public DeniedEntityType getDeniedEntityType() {
+        return deniedEntityType;
+    }
+
+    public String getDeniedEntity() {
+        return deniedEntity;
+    }
+
+    @Override
+    public final boolean equals(Object o) {
+        if (o instanceof DropListEntry dropListEntry) {
+            return Objects.equals(ownerScope, dropListEntry.ownerScope) &&
+                Objects.equals(owner, dropListEntry.owner) &&
+                Objects.equals(deniedEntityType, dropListEntry.deniedEntityType) &&
+                Objects.equals(deniedEntity, dropListEntry.deniedEntity);
+        }
+        return false;
+    }
+
+    @Override
+    public final int hashCode() {
+        return Objects.hash(ownerScope, owner, deniedEntityType, deniedEntity);
+    }
+
+    @Override
+    public String toString() {
+        MoreObjects.ToStringHelper result = MoreObjects.toStringHelper(this)
+            .add("ownerScope", ownerScope);
+        owner.ifPresent(o -> result.add("owner", o));
+        result.add("deniedType", deniedEntityType)
+            .add("deniedEntity", deniedEntity);
+        return result.toString();
+    }
+}
\ No newline at end of file
diff --git a/server/data/data-api/src/main/java/org/apache/james/droplists/api/OwnerScope.java b/server/data/data-api/src/main/java/org/apache/james/droplists/api/OwnerScope.java
new file mode 100644
index 0000000..7f172a6
--- /dev/null
+++ b/server/data/data-api/src/main/java/org/apache/james/droplists/api/OwnerScope.java
@@ -0,0 +1,37 @@
+/****************************************************************
+ * 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.droplists.api;
+
+/**
+ * Enum representing the scope of ownership that can be used when managing email droplists.
+ */
+public enum OwnerScope {
+    /**
+     * Represents domain and user level ownership
+     */
+    GLOBAL,
+    /**
+     * Represents ownership at the domain level.
+     */
+    DOMAIN,
+    /**
+     * Represents ownership at the user level.
+     */
+    USER
+}
diff --git a/server/data/data-api/src/test/java/org/apache/james/droplists/api/DropListContract.java b/server/data/data-api/src/test/java/org/apache/james/droplists/api/DropListContract.java
new file mode 100644
index 0000000..54dff10
--- /dev/null
+++ b/server/data/data-api/src/test/java/org/apache/james/droplists/api/DropListContract.java
@@ -0,0 +1,188 @@
+/****************************************************************
+ * 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.droplists.api;
+
+import static org.apache.james.droplists.api.OwnerScope.GLOBAL;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+import java.util.stream.Stream;
+
+import jakarta.mail.internet.AddressException;
+
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
+import org.junit.jupiter.api.Test;
+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;
+import reactor.core.publisher.Mono;
+
+public interface DropListContract {
+
+    DropList dropList();
+
+    @ParameterizedTest(name = "{index} {0}")
+    @MethodSource("provideParametersForGetEntryListTest")
+    default void shouldAddEntry(DropListEntry dropListEntry) {
+        dropList().add(dropListEntry).block();
+
+        assertThat(dropList().list(dropListEntry.getOwnerScope(), dropListEntry.getOwner()).collectList().block().size()).isEqualTo(1);
+    }
+
+    @ParameterizedTest(name = "{index} {0}")
+    @MethodSource("provideParametersForGetEntryListTest")
+    default void shouldRemoveEntry(DropListEntry dropListEntry) {
+        dropList().add(dropListEntry).block();
+        dropList().remove(dropListEntry).block();
+
+        assertThat(dropList().list(dropListEntry.getOwnerScope(), dropListEntry.getOwner()).collectList().block().size()).isZero();
+    }
+
+    @Test
+    default void shouldThrowWhenAddOnNullDropListEntry() {
+        assertThatThrownBy(() -> dropList().add(null))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void shouldThrowWhenRemoveOnNullDropListEntry() {
+        assertThatThrownBy(() -> dropList().remove(null))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void shouldThrowWhenListOnNullScope() {
+        assertThatThrownBy(() -> dropList().list(null, "owner"))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void shouldThrowWhenListOnNullOwner() {
+        assertThatThrownBy(() -> dropList().list(GLOBAL, null))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void shouldThrowWhenQueryOnNullScope() {
+        assertThatThrownBy(() -> dropList().query(null, "owner", new MailAddress("sender@example.com")))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void shouldThrowWhenQueryOnNullOwner() {
+        assertThatThrownBy(() -> dropList().query(GLOBAL, null, new MailAddress("sender@example.com")))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    default void shouldThrowWhenQueryOnNullSender() {
+        assertThatThrownBy(() -> dropList().query(GLOBAL, "owner", null))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @ParameterizedTest(name = "{index} {0}")
+    @MethodSource("provideParametersForGetEntryListTest")
+    default void shouldGetEntryListForSpecifiedScopeAndOwner(DropListEntry dropListEntry) {
+        dropList().add(dropListEntry).block();
+
+        Flux<DropListEntry> result = dropList().list(dropListEntry.getOwnerScope(), dropListEntry.getOwner());
+
+        assertThat(result.collectList().block().size()).isEqualTo(1);
+    }
+
+
+    @ParameterizedTest(name = "{index} {0}, sender: {1}, recipient: {2}")
+    @MethodSource("provideParametersForReturnAllowedTest")
+    default void shouldReturnAllowed(DropListEntry dropListEntry, MailAddress senderMailAddress, String recipient) {
+        dropList().add(dropListEntry).block();
+
+        Mono<DropList.Status> result = dropList().query(dropListEntry.getOwnerScope(), recipient, senderMailAddress);
+
+        assertThat(result.block()).isEqualTo(DropList.Status.ALLOWED);
+    }
+
+    @ParameterizedTest(name = "{index} {0}, sender: {1}, recipient: {2}")
+    @MethodSource("provideParametersForReturnBlockedTest")
+    default void shouldReturnBlocked(DropListEntry dropListEntry, MailAddress senderMailAddress, String recipient) {
+        dropList().add(dropListEntry).block();
+
+        Mono<DropList.Status> result = dropList().query(dropListEntry.getOwnerScope(), recipient, senderMailAddress);
+
+        assertThat(result.block()).isEqualTo(DropList.Status.BLOCKED);
+    }
+
+    static Stream<DropListEntry> getDropListTestEntries() throws AddressException {
+        return Stream.of(
+            DropListEntry.builder()
+                .forAll()
+                .denyAddress(new MailAddress("denied@denied.com"))
+                .build(),
+            DropListEntry.builder()
+                .forAll()
+                .denyDomain(Domain.of("denied.com"))
+                .build(),
+            DropListEntry.builder()
+                .domainOwner(Domain.of("example.com"))
+                .denyAddress(new MailAddress("denied@denied.com"))
+                .build(),
+            DropListEntry.builder()
+                .domainOwner(Domain.of("example.com"))
+                .denyDomain(Domain.of("denied.com"))
+                .build(),
+            DropListEntry.builder()
+                .userOwner(new MailAddress("owner@example.com"))
+                .denyAddress(new MailAddress("denied@denied.com"))
+                .build(),
+            DropListEntry.builder()
+                .userOwner(new MailAddress("owner@example.com"))
+                .denyDomain(Domain.of("denied.com"))
+                .build());
+    }
+
+    static Stream<Arguments> provideParametersForGetEntryListTest() throws AddressException {
+        return getDropListTestEntries().map(Arguments::of);
+    }
+
+    static Stream<Arguments> provideParametersForReturnAllowedTest() throws AddressException {
+        MailAddress recipientAddress = new MailAddress("owner@example.com");
+        MailAddress allowedSenderAddress = new MailAddress("allowed@allowed.com");
+        return getDropListTestEntries().map(dropListEntry -> Arguments.of(dropListEntry, allowedSenderAddress, recipientAddress.asString()));
+    }
+
+    static Stream<Arguments> provideParametersForReturnBlockedTest() throws AddressException {
+        MailAddress recipientAddress = new MailAddress("owner@example.com");
+        MailAddress deniedSenderAddress = new MailAddress("denied@denied.com");
+        MailAddress deniedSenderDomain = new MailAddress("allowed@denied.com");
+        return getDropListTestEntries().map(dropListEntry -> {
+
+            if (dropListEntry.getOwnerScope().equals(OwnerScope.DOMAIN)) {
+                return dropListEntry.getDeniedEntityType().equals(DeniedEntityType.DOMAIN) ?
+                    Arguments.of(dropListEntry, deniedSenderDomain, recipientAddress.getDomain().asString()) :
+                    Arguments.of(dropListEntry, deniedSenderAddress, recipientAddress.getDomain().asString());
+            } else {
+                return dropListEntry.getDeniedEntityType().equals(DeniedEntityType.DOMAIN) ?
+                    Arguments.of(dropListEntry, deniedSenderDomain, recipientAddress.asString()) :
+                    Arguments.of(dropListEntry, deniedSenderAddress, recipientAddress.asString());
+            }
+        });
+    }
+}
diff --git a/server/data/data-api/src/test/java/org/apache/james/droplists/api/DropListEntryTest.java b/server/data/data-api/src/test/java/org/apache/james/droplists/api/DropListEntryTest.java
new file mode 100644
index 0000000..79d6f2a
--- /dev/null
+++ b/server/data/data-api/src/test/java/org/apache/james/droplists/api/DropListEntryTest.java
@@ -0,0 +1,142 @@
+/****************************************************************
+ * 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.droplists.api;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+import jakarta.mail.internet.AddressException;
+
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
+import org.junit.jupiter.api.Test;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+
+class DropListEntryTest {
+
+    @Test
+    void shouldRespectEqualsContract() {
+        EqualsVerifier.forClass(DropListEntry.class)
+            .verify();
+    }
+
+    @Test
+    void shouldThrowOnWhenBuilderIsEmpty() {
+        DropListEntry.Builder builder = DropListEntry.builder();
+
+        assertThatThrownBy(builder::build)
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void shouldThrowOnWithoutOwnerScope() {
+        DropListEntry.Builder builder = DropListEntry.builder()
+            .denyDomain(Domain.of("denied.com"));
+
+        assertThatThrownBy(builder::build)
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void shouldThrowOnNullUserOwner() {
+        DropListEntry.Builder builder = DropListEntry.builder();
+
+        assertThatThrownBy(() -> builder.userOwner(null))
+            .isInstanceOf(NullPointerException.class);
+    }
+
+    @Test
+    void shouldThrowOnNullDomainOwner() {
+        DropListEntry.Builder builder = DropListEntry.builder();
+
+        assertThatThrownBy(() -> builder.domainOwner(null))
+            .isInstanceOf(NullPointerException.class);
+    }
+
+    @Test
+    void shouldThrowOnWithoutDeniedEntity() {
+        DropListEntry.Builder builder = DropListEntry.builder()
+            .forAll();
+
+        assertThatThrownBy(builder::build)
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void shouldThrowOnNullDeniedDomain() {
+        DropListEntry.Builder builder = DropListEntry.builder()
+            .forAll();
+
+        assertThatThrownBy(() -> builder.denyDomain(null))
+            .isInstanceOf(NullPointerException.class);
+    }
+
+    @Test
+    void shouldThrowOnNullDeniedMailAddress() {
+        DropListEntry.Builder builder = DropListEntry.builder()
+            .forAll();
+
+        assertThatThrownBy(() -> builder.denyAddress(null))
+            .isInstanceOf(NullPointerException.class);
+    }
+
+    @Test
+    void shouldGlobalOwnerScopeBeSetWhenForAllIsCalled() {
+        DropListEntry dropListEntry = DropListEntry.builder()
+            .forAll()
+            .denyDomain(Domain.of("denied.com"))
+            .build();
+
+        assertThat(dropListEntry.getOwnerScope()).isEqualTo(OwnerScope.GLOBAL);
+    }
+
+    @Test
+    void shouldEmptyOwnerBeSetWhenForAllIsCalled() throws AddressException {
+        DropListEntry dropListEntry = DropListEntry.builder()
+            .forAll()
+            .denyAddress(new MailAddress("denied@example.com"))
+            .build();
+
+        assertThat(dropListEntry.getOwner()).isEmpty();
+    }
+
+    @Test
+    void shouldReturnDropListEntryAsString() throws AddressException {
+        String expectedString = "DropListEntry{ownerScope=USER, owner=owner@example.com, deniedType=DOMAIN, deniedEntity=denied.com}";
+        DropListEntry dropListEntry = DropListEntry.builder()
+            .userOwner(new MailAddress("owner@example.com"))
+            .denyDomain(Domain.of("denied.com"))
+            .build();
+
+        assertThat(dropListEntry).hasToString(expectedString);
+    }
+
+    @Test
+    void shouldReturnDropListEntryAsStringWithoutOwnerWhenScopeGlobal() {
+        String expectedString = "DropListEntry{ownerScope=GLOBAL, deniedType=DOMAIN, deniedEntity=denied.com}";
+        DropListEntry dropListEntry = DropListEntry.builder()
+            .forAll()
+            .denyDomain(Domain.of("denied.com"))
+            .build();
+
+        assertThat(dropListEntry).hasToString(expectedString);
+    }
+
+}
\ No newline at end of file
diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropList.java b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropList.java
new file mode 100644
index 0000000..98c5fd0
--- /dev/null
+++ b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropList.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.droplists.cassandra;
+
+import jakarta.inject.Inject;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+
+import com.google.common.base.Preconditions;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public class CassandraDropList implements DropList {
+
+    private final CassandraDropListDAO dropListDAO;
+
+    @Inject
+    public CassandraDropList(CassandraDropListDAO dropListDAO) {
+        this.dropListDAO = dropListDAO;
+    }
+
+    @Override
+    public Mono<Void> add(DropListEntry entry) {
+        Preconditions.checkArgument(entry != null);
+        return dropListDAO.addDropList(entry);
+    }
+
+    @Override
+    public Mono<Void> remove(DropListEntry entry) {
+        Preconditions.checkArgument(entry != null);
+        return dropListDAO.removeDropList(entry);
+    }
+
+    @Override
+    public Flux<DropListEntry> list(OwnerScope ownerScope, String owner) {
+        Preconditions.checkArgument(ownerScope != null);
+        Preconditions.checkArgument(owner != null);
+        return dropListDAO.getDropList(ownerScope, owner);
+    }
+
+    @Override
+    public Mono<Status> query(OwnerScope ownerScope, String owner, MailAddress sender) {
+        Preconditions.checkArgument(ownerScope != null);
+        Preconditions.checkArgument(owner != null);
+        Preconditions.checkArgument(sender != null);
+        return dropListDAO.queryDropList(ownerScope, owner, sender);
+    }
+}
\ No newline at end of file
diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListDAO.java b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListDAO.java
new file mode 100644
index 0000000..d584791
--- /dev/null
+++ b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListDAO.java
@@ -0,0 +1,163 @@
+/****************************************************************
+ * 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.droplists.cassandra;
+
+import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.bindMarker;
+import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.deleteFrom;
+import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.insertInto;
+import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.selectFrom;
+import static com.datastax.oss.driver.api.querybuilder.relation.Relation.column;
+import static org.apache.james.droplists.api.DeniedEntityType.DOMAIN;
+import static org.apache.james.droplists.cassandra.tables.CassandraDropListTable.DENIED_ENTITY;
+import static org.apache.james.droplists.cassandra.tables.CassandraDropListTable.DENIED_ENTITY_TYPE;
+import static org.apache.james.droplists.cassandra.tables.CassandraDropListTable.OWNER;
+import static org.apache.james.droplists.cassandra.tables.CassandraDropListTable.OWNER_SCOPE;
+import static org.apache.james.droplists.cassandra.tables.CassandraDropListTable.TABLE_NAME;
+
+import java.util.List;
+
+import jakarta.inject.Inject;
+import jakarta.mail.internet.AddressException;
+
+import org.apache.james.backends.cassandra.utils.CassandraAsyncExecutor;
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.cql.PreparedStatement;
+import com.datastax.oss.driver.api.core.cql.Row;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public class CassandraDropListDAO {
+
+    private final CassandraAsyncExecutor executor;
+    private final PreparedStatement addDropListStatement;
+    private final PreparedStatement removeDropListStatement;
+    private final PreparedStatement getDropListStatement;
+    private final PreparedStatement queryDropListStatement;
+    private final PreparedStatement queryGlobalDropListStatement;
+
+    @Inject
+    public CassandraDropListDAO(CqlSession session) {
+        this.executor = new CassandraAsyncExecutor(session);
+
+        addDropListStatement = session.prepare(insertInto(TABLE_NAME)
+            .value(OWNER_SCOPE, bindMarker(OWNER_SCOPE))
+            .value(OWNER, bindMarker(OWNER))
+            .value(DENIED_ENTITY, bindMarker(DENIED_ENTITY))
+            .value(DENIED_ENTITY_TYPE, bindMarker(DENIED_ENTITY_TYPE))
+            .ifNotExists()
+            .build());
+
+        removeDropListStatement = session.prepare(deleteFrom(TABLE_NAME)
+            .where(column(OWNER_SCOPE).isEqualTo(bindMarker(OWNER_SCOPE)),
+                column(OWNER).isEqualTo(bindMarker(OWNER)),
+                column(DENIED_ENTITY).isEqualTo(bindMarker(DENIED_ENTITY)))
+            .ifExists()
+            .build());
+
+        getDropListStatement = session.prepare(selectFrom(TABLE_NAME)
+            .all()
+            .where(column(OWNER_SCOPE).isEqualTo(bindMarker(OWNER_SCOPE)),
+                column(OWNER).isEqualTo(bindMarker(OWNER)))
+            .allowFiltering()
+            .build());
+
+        queryDropListStatement = session.prepare(selectFrom(TABLE_NAME)
+            .all()
+            .where(column(OWNER_SCOPE).isEqualTo(bindMarker(OWNER_SCOPE)),
+                column(OWNER).isEqualTo(bindMarker(OWNER)),
+                column(DENIED_ENTITY).in(bindMarker(DENIED_ENTITY)))
+            .build());
+
+        queryGlobalDropListStatement = session.prepare(selectFrom(TABLE_NAME)
+            .all()
+            .where(column(OWNER_SCOPE).isEqualTo(bindMarker(OWNER_SCOPE)),
+                column(DENIED_ENTITY).in(bindMarker(DENIED_ENTITY)))
+            .allowFiltering()
+            .build());
+    }
+
+    public Mono<Void> addDropList(DropListEntry dropListEntry) {
+        return executor.executeVoid(
+            addDropListStatement.bind()
+                .setString(OWNER_SCOPE, dropListEntry.getOwnerScope().name())
+                .setString(OWNER, dropListEntry.getOwner())
+                .setString(DENIED_ENTITY, dropListEntry.getDeniedEntity())
+                .setString(DENIED_ENTITY_TYPE, dropListEntry.getDeniedEntityType().name()));
+    }
+
+    public Mono<Void> removeDropList(DropListEntry dropListEntry) {
+        return executor.executeVoid(
+            removeDropListStatement.bind()
+                .setString(OWNER_SCOPE, dropListEntry.getOwnerScope().name())
+                .setString(OWNER, dropListEntry.getOwner())
+                .setString(DENIED_ENTITY, dropListEntry.getDeniedEntity()));
+    }
+
+    public Mono<DropList.Status> queryDropList(OwnerScope ownerScope, String owner, MailAddress sender) {
+        if (ownerScope.equals(OwnerScope.GLOBAL)) {
+            return executor.executeReturnExists(
+                    queryGlobalDropListStatement.bind()
+                        .setString(OWNER_SCOPE, ownerScope.name())
+                        .setList(DENIED_ENTITY, List.of(sender.asString(), sender.getDomain().asString()), String.class))
+                .map(isExist -> Boolean.TRUE.equals(isExist) ? DropList.Status.BLOCKED : DropList.Status.ALLOWED);
+        } else {
+            return executor.executeReturnExists(
+                    queryDropListStatement.bind()
+                        .setString(OWNER_SCOPE, ownerScope.name())
+                        .setString(OWNER, owner)
+                        .setList(DENIED_ENTITY, List.of(sender.asString(), sender.getDomain().asString()), String.class))
+                .map(isExist -> Boolean.TRUE.equals(isExist) ? DropList.Status.BLOCKED : DropList.Status.ALLOWED);
+        }
+    }
+
+    public Flux<DropListEntry> getDropList(OwnerScope ownerScope, String owner) {
+        return executor.executeRows(getDropListStatement.bind()
+                .setString(OWNER_SCOPE, ownerScope.name())
+                .setString(OWNER, owner))
+            .map(row -> mapRowToDropListEntry(ownerScope, row));
+    }
+
+    private static DropListEntry mapRowToDropListEntry(OwnerScope ownerScope, Row row) {
+        String deniedEntity = row.getString(DENIED_ENTITY);
+        String deniedEntityType = row.getString(DENIED_ENTITY_TYPE);
+        try {
+            DropListEntry.Builder builder = DropListEntry.builder();
+            switch (ownerScope) {
+                case USER -> builder.userOwner(new MailAddress(row.getString(OWNER)));
+                case DOMAIN -> builder.domainOwner(Domain.of(row.getString(OWNER)));
+                case GLOBAL -> builder.forAll();
+            }
+            if (DOMAIN.name().equals(deniedEntityType)) {
+                builder.denyDomain(Domain.of(deniedEntity));
+            } else {
+                builder.denyAddress(new MailAddress(deniedEntity));
+            }
+            return builder.build();
+        } catch (AddressException e) {
+            throw new IllegalArgumentException("Entity could not be parsed as a MailAddress", e);
+        }
+    }
+}
\ No newline at end of file
diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListModule.java b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListModule.java
new file mode 100644
index 0000000..b555c1c
--- /dev/null
+++ b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListModule.java
@@ -0,0 +1,35 @@
+/****************************************************************
+ * 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.droplists.cassandra;
+
+import org.apache.james.backends.cassandra.components.CassandraModule;
+import org.apache.james.droplists.cassandra.tables.CassandraDropListTable;
+
+import com.datastax.oss.driver.api.core.type.DataTypes;
+
+public interface CassandraDropListModule {
+    CassandraModule MODULE = CassandraModule.table(CassandraDropListTable.TABLE_NAME)
+        .comment("Holds DropLists of this James server.")
+        .statement(statement -> types -> statement
+            .withPartitionKey(CassandraDropListTable.OWNER_SCOPE, DataTypes.TEXT)
+            .withPartitionKey(CassandraDropListTable.OWNER, DataTypes.TEXT)
+            .withPartitionKey(CassandraDropListTable.DENIED_ENTITY, DataTypes.TEXT)
+            .withColumn(CassandraDropListTable.DENIED_ENTITY_TYPE, DataTypes.TEXT))
+        .build();
+}
\ No newline at end of file
diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/tables/CassandraDropListTable.java b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/tables/CassandraDropListTable.java
new file mode 100644
index 0000000..5a42a98
--- /dev/null
+++ b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/tables/CassandraDropListTable.java
@@ -0,0 +1,30 @@
+/****************************************************************
+ * 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.droplists.cassandra.tables;
+
+import com.datastax.oss.driver.api.core.CqlIdentifier;
+
+public interface CassandraDropListTable {
+
+    String TABLE_NAME = "droplist";
+    CqlIdentifier OWNER_SCOPE = CqlIdentifier.fromCql("owner_scope");
+    CqlIdentifier OWNER = CqlIdentifier.fromCql("owner");
+    CqlIdentifier DENIED_ENTITY_TYPE = CqlIdentifier.fromCql("denied_entity_type");
+    CqlIdentifier DENIED_ENTITY = CqlIdentifier.fromCql("denied_entity");
+}
\ No newline at end of file
diff --git a/server/data/data-cassandra/src/test/java/org/apache/james/droplists/cassandra/CassandraDropListTest.java b/server/data/data-cassandra/src/test/java/org/apache/james/droplists/cassandra/CassandraDropListTest.java
new file mode 100644
index 0000000..0871d00
--- /dev/null
+++ b/server/data/data-cassandra/src/test/java/org/apache/james/droplists/cassandra/CassandraDropListTest.java
@@ -0,0 +1,43 @@
+/****************************************************************
+ * 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.droplists.cassandra;
+
+import org.apache.james.backends.cassandra.CassandraCluster;
+import org.apache.james.backends.cassandra.CassandraClusterExtension;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListContract;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class CassandraDropListTest implements DropListContract {
+    @RegisterExtension
+    static CassandraClusterExtension cassandraCluster = new CassandraClusterExtension(CassandraDropListModule.MODULE);
+
+    DropList dropList;
+
+    @BeforeEach
+    public void setUp(CassandraCluster cassandra) throws Exception {
+        dropList = new CassandraDropList(new CassandraDropListDAO(cassandra.getConf()));
+    }
+
+    @Override
+    public DropList dropList() {
+        return dropList;
+    }
+}
\ No newline at end of file
diff --git a/server/data/data-memory/src/main/java/org/apache/james/droplists/memory/MemoryDropList.java b/server/data/data-memory/src/main/java/org/apache/james/droplists/memory/MemoryDropList.java
new file mode 100644
index 0000000..e7a5a7f
--- /dev/null
+++ b/server/data/data-memory/src/main/java/org/apache/james/droplists/memory/MemoryDropList.java
@@ -0,0 +1,99 @@
+/****************************************************************
+ * 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.droplists.memory;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DeniedEntityType;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public class MemoryDropList implements DropList {
+
+    private final Multimap<OwnerScope, DropListEntry> globalDropList = Multimaps.synchronizedMultimap(HashMultimap.create());
+    private final Multimap<OwnerScope, DropListEntry> domainDropList = Multimaps.synchronizedMultimap(HashMultimap.create());
+    private final Multimap<OwnerScope, DropListEntry> userDropList = Multimaps.synchronizedMultimap(HashMultimap.create());
+
+    @Override
+    public Mono<Void> add(DropListEntry entry) {
+        Preconditions.checkArgument(entry != null);
+        OwnerScope ownerScope = entry.getOwnerScope();
+        Multimap<OwnerScope, DropListEntry> selectedDropList = getDropListByScope(ownerScope);
+        return Mono.fromRunnable(() -> selectedDropList.put(ownerScope, entry));
+    }
+
+    @Override
+    public Mono<Void> remove(DropListEntry entry) {
+        Preconditions.checkArgument(entry != null);
+        OwnerScope ownerScope = entry.getOwnerScope();
+        Multimap<OwnerScope, DropListEntry> selectedDropList = getDropListByScope(ownerScope);
+        return Mono.fromRunnable(() -> selectedDropList.remove(ownerScope, entry));
+    }
+
+    @Override
+    public Flux<DropListEntry> list(OwnerScope ownerScope, String owner) {
+        Preconditions.checkArgument(ownerScope != null);
+        Preconditions.checkArgument(owner != null);
+        Multimap<OwnerScope, DropListEntry> selectedDropList = getDropListByScope(ownerScope);
+        return Flux.fromIterable(selectedDropList.get(ownerScope))
+            .filter(entry -> entry.getOwner().equals(owner));
+    }
+
+    @Override
+    public Mono<Status> query(OwnerScope ownerScope, String owner, MailAddress sender) {
+        Preconditions.checkArgument(ownerScope != null);
+        Preconditions.checkArgument(owner != null);
+        Preconditions.checkArgument(sender != null);
+        Multimap<OwnerScope, DropListEntry> selectedDropList = getDropListByScope(ownerScope);
+        boolean isBlocked = selectedDropList.get(ownerScope).stream()
+            .anyMatch(entry -> isEntryMatchingOwner(ownerScope, owner, entry) && isEntryMatchingDeniedEntity(sender, entry));
+
+        return Mono.just(isBlocked ? Status.BLOCKED : Status.ALLOWED);
+    }
+
+    private Multimap<OwnerScope, DropListEntry> getDropListByScope(OwnerScope ownerScope) {
+        return switch (ownerScope) {
+            case GLOBAL -> globalDropList;
+            case DOMAIN -> domainDropList;
+            case USER -> userDropList;
+        };
+    }
+
+    private boolean isEntryMatchingOwner(OwnerScope ownerScope, String owner, DropListEntry entry) {
+        if (ownerScope.equals(OwnerScope.GLOBAL)) {
+            return true;
+        } else {
+            return entry.getOwner().equals(owner);
+        }
+    }
+
+    private boolean isEntryMatchingDeniedEntity(MailAddress sender, DropListEntry entry) {
+        String entityFromSender = entry.getDeniedEntityType() == DeniedEntityType.DOMAIN ? sender.getDomain().asString() : sender.asString();
+
+        return entry.getDeniedEntity().equals(entityFromSender);
+    }
+}
\ No newline at end of file
diff --git a/server/data/data-memory/src/test/java/org/apache/james/droplists/memory/MemoryDropListTest.java b/server/data/data-memory/src/test/java/org/apache/james/droplists/memory/MemoryDropListTest.java
new file mode 100644
index 0000000..d78cfcc
--- /dev/null
+++ b/server/data/data-memory/src/test/java/org/apache/james/droplists/memory/MemoryDropListTest.java
@@ -0,0 +1,39 @@
+/****************************************************************
+ * 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.droplists.memory;
+
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListContract;
+import org.junit.jupiter.api.BeforeEach;
+
+class MemoryDropListTest implements DropListContract {
+
+    DropList dropList;
+
+    @BeforeEach
+    void setup() {
+        dropList = new MemoryDropList();
+    }
+
+    @Override
+    public DropList dropList() {
+        return dropList;
+    }
+
+}
\ No newline at end of file
diff --git a/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java
new file mode 100644
index 0000000..ff46ee7
--- /dev/null
+++ b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java
@@ -0,0 +1,127 @@
+/****************************************************************
+ * 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.droplists.postgres;
+
+import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT;
+import static org.apache.james.droplists.api.DeniedEntityType.DOMAIN;
+import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.DENIED_ENTITY;
+import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.DENIED_ENTITY_TYPE;
+import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.DROPLIST_ID;
+import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.OWNER;
+import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.OWNER_SCOPE;
+import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.TABLE_NAME;
+
+import java.util.List;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.mail.internet.AddressException;
+
+import org.apache.james.backends.postgres.utils.PostgresExecutor;
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+import org.jooq.Record;
+
+import com.google.common.base.Preconditions;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public class PostgresDropList implements DropList {
+    private final PostgresExecutor postgresExecutor;
+
+    @Inject
+    public PostgresDropList(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) {
+        this.postgresExecutor = postgresExecutor;
+    }
+
+    @Override
+    public Mono<Void> add(DropListEntry entry) {
+        Preconditions.checkArgument(entry != null);
+        String specifiedOwner = entry.getOwnerScope().equals(OwnerScope.GLOBAL) ? "" : entry.getOwner();
+        return postgresExecutor.executeVoid(dslContext ->
+            Mono.from(dslContext.insertInto(TABLE_NAME, DROPLIST_ID, OWNER_SCOPE, OWNER, DENIED_ENTITY_TYPE, DENIED_ENTITY)
+                .values(UUID.randomUUID(),
+                    entry.getOwnerScope().name(),
+                    specifiedOwner,
+                    entry.getDeniedEntityType().name(),
+                    entry.getDeniedEntity())
+            )
+        );
+    }
+
+    @Override
+    public Mono<Void> remove(DropListEntry entry) {
+        Preconditions.checkArgument(entry != null);
+        return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME)
+            .where(OWNER_SCOPE.eq(entry.getOwnerScope().name()))
+            .and(OWNER.eq(entry.getOwner()))
+            .and(DENIED_ENTITY.eq(entry.getDeniedEntity()))));
+    }
+
+    @Override
+    public Flux<DropListEntry> list(OwnerScope ownerScope, String owner) {
+        Preconditions.checkArgument(ownerScope != null);
+        Preconditions.checkArgument(owner != null);
+        return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME)
+                .where(OWNER_SCOPE.eq(ownerScope.name()))
+                .and(OWNER.eq(owner))))
+            .map(PostgresDropList::mapRecordToDropListEntry);
+    }
+
+    @Override
+    public Mono<Status> query(OwnerScope ownerScope, String owner, MailAddress sender) {
+        Preconditions.checkArgument(ownerScope != null);
+        Preconditions.checkArgument(owner != null);
+        Preconditions.checkArgument(sender != null);
+        String specifiedOwner = ownerScope.equals(OwnerScope.GLOBAL) ? "" : owner;
+        return postgresExecutor.executeExists(dsl -> dsl.selectOne().from(TABLE_NAME)
+                .where(OWNER_SCOPE.eq(ownerScope.name()))
+                .and(OWNER.eq(specifiedOwner))
+                .and(DENIED_ENTITY.in(List.of(sender.asString(), sender.getDomain().asString()))))
+            .map(isExist -> Boolean.TRUE.equals(isExist) ? DropList.Status.BLOCKED : DropList.Status.ALLOWED);
+    }
+
+    private static DropListEntry mapRecordToDropListEntry(Record dropListRecord) {
+        String deniedEntity = dropListRecord.get(DENIED_ENTITY);
+        String deniedEntityType = dropListRecord.get(DENIED_ENTITY_TYPE);
+        OwnerScope ownerScope = OwnerScope.valueOf(dropListRecord.get(OWNER_SCOPE));
+        try {
+            DropListEntry.Builder builder = DropListEntry.builder();
+            switch (ownerScope) {
+                case USER -> builder.userOwner(new MailAddress(dropListRecord.get(OWNER)));
+                case DOMAIN -> builder.domainOwner(Domain.of(dropListRecord.get(OWNER)));
+                case GLOBAL -> builder.forAll();
+            }
+            if (DOMAIN.name().equals(deniedEntityType)) {
+                builder.denyDomain(Domain.of(deniedEntity));
+            } else {
+                builder.denyAddress(new MailAddress(deniedEntity));
+            }
+            return builder.build();
+        } catch (AddressException e) {
+            throw new IllegalArgumentException("Entity could not be parsed as a MailAddress", e);
+        }
+    }
+}
diff --git a/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.java b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.java
new file mode 100644
index 0000000..6d1d50a
--- /dev/null
+++ b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.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.droplists.postgres;
+
+import java.util.UUID;
+
+import org.apache.james.backends.postgres.PostgresIndex;
+import org.apache.james.backends.postgres.PostgresModule;
+import org.apache.james.backends.postgres.PostgresTable;
+import org.jooq.Field;
+import org.jooq.Record;
+import org.jooq.Table;
+import org.jooq.impl.DSL;
+import org.jooq.impl.SQLDataType;
+
+public interface PostgresDropListModule {
+    interface PostgresDropListsTable {
+        Table<Record> TABLE_NAME = DSL.table("droplist");
+
+        Field<UUID> DROPLIST_ID = DSL.field("droplist_id", SQLDataType.UUID.notNull());
+        Field<String> OWNER_SCOPE = DSL.field("owner_scope", SQLDataType.VARCHAR);
+        Field<String> OWNER = DSL.field("owner", SQLDataType.VARCHAR);
+        Field<String> DENIED_ENTITY_TYPE = DSL.field("denied_entity_type", SQLDataType.VARCHAR);
+        Field<String> DENIED_ENTITY = DSL.field("denied_entity", SQLDataType.VARCHAR);
+
+        PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName())
+            .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName)
+                .column(DROPLIST_ID)
+                .column(OWNER_SCOPE)
+                .column(OWNER)
+                .column(DENIED_ENTITY_TYPE)
+                .column(DENIED_ENTITY)
+                .constraint(DSL.primaryKey(DROPLIST_ID))))
+            .disableRowLevelSecurity()
+            .build();
+
+        PostgresIndex IDX_OWNER_SCOPE_OWNER = PostgresIndex.name("idx_owner_scope_owner")
+            .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName)
+                .on(TABLE_NAME, OWNER_SCOPE, OWNER));
+
+        PostgresIndex IDX_OWNER_SCOPE_OWNER_DENIED_ENTITY = PostgresIndex.name("idx_owner_scope_owner_denied_entity")
+            .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName)
+                .on(TABLE_NAME, OWNER_SCOPE, OWNER, DENIED_ENTITY));
+    }
+
+    PostgresModule MODULE = PostgresModule.builder()
+        .addTable(PostgresDropListsTable.TABLE)
+        .addIndex(PostgresDropListsTable.IDX_OWNER_SCOPE_OWNER)
+        .addIndex(PostgresDropListsTable.IDX_OWNER_SCOPE_OWNER_DENIED_ENTITY)
+        .build();
+}
diff --git a/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java b/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java
new file mode 100644
index 0000000..99c5dc7
--- /dev/null
+++ b/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java
@@ -0,0 +1,43 @@
+/****************************************************************
+ * 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.droplists.postgres;
+
+import org.apache.james.backends.postgres.PostgresExtension;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListContract;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class PostgresDropListsTest implements DropListContract {
+    @RegisterExtension
+    static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresDropListModule.MODULE);
+
+    PostgresDropList dropList;
+
+    @BeforeEach
+    void setup() {
+        dropList = new PostgresDropList(postgresExtension.getPostgresExecutor());
+    }
+
+    @Override
+    public DropList dropList() {
+        return dropList;
+    }
+}
\ No newline at end of file
diff --git a/server/mailet/mailets/src/main/java/org/apache/james/transport/matchers/IsInDropList.java b/server/mailet/mailets/src/main/java/org/apache/james/transport/matchers/IsInDropList.java
new file mode 100644
index 0000000..5f29539
--- /dev/null
+++ b/server/mailet/mailets/src/main/java/org/apache/james/transport/matchers/IsInDropList.java
@@ -0,0 +1,79 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+package org.apache.james.transport.matchers;
+
+import static org.apache.james.droplists.api.DropList.Status.ALLOWED;
+import static org.apache.james.droplists.api.OwnerScope.DOMAIN;
+import static org.apache.james.droplists.api.OwnerScope.GLOBAL;
+import static org.apache.james.droplists.api.OwnerScope.USER;
+import static reactor.function.TupleUtils.function;
+
+import java.util.Collection;
+
+import jakarta.inject.Inject;
+import jakarta.mail.MessagingException;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DropList;
+import org.apache.mailet.Mail;
+import org.apache.mailet.base.GenericMatcher;
+
+import com.google.common.collect.ImmutableList;
+
+import reactor.core.publisher.Mono;
+
+/**
+ * This matcher that checks if a mail sender is permitted based on their status in the DropList.
+ *
+ * <p>Implements the match method to check if the sender of the incoming mail is not listed in the DropList.
+ * If the sender is not found in the DropList, the matcher will return all recipients of the mail for which the sender is allowed.</p>
+ * </p>Note:</p>
+ * managing DropLists can be accomplished through <a href="http://james.apache.org/server/manage-webadmin.html">WebAdmin</a>.
+ **/
+public class IsInDropList extends GenericMatcher {
+
+    private final DropList dropList;
+
+    @Inject
+    public IsInDropList(DropList dropList) {
+        this.dropList = dropList;
+    }
+
+    @Override
+    public Collection<MailAddress> match(Mail mail) throws MessagingException {
+        return mail.getRecipients()
+            .stream()
+            .filter(recipient -> isRecipientAllowed(mail, recipient))
+            .collect(ImmutableList.toImmutableList());
+    }
+
+    private Boolean isRecipientAllowed(Mail mail, MailAddress recipient) {
+        MailAddress sender = mail.getMaybeSender().get();
+        Mono<DropList.Status> globalStatusQuery = dropList.query(GLOBAL, recipient.asString(), sender);
+        Mono<DropList.Status> domainStatusQuery = dropList.query(DOMAIN, recipient.getDomain().asString(), sender);
+        Mono<DropList.Status> userStatusQuery = dropList.query(USER, recipient.asString(), sender);
+        return Mono.zip(globalStatusQuery, domainStatusQuery, userStatusQuery)
+            .map(function(IsInDropList::isAllowed))
+            .block();
+    }
+
+    private static boolean isAllowed(DropList.Status globalStatus, DropList.Status domainStatus, DropList.Status userStatus) {
+        return globalStatus == ALLOWED && domainStatus == ALLOWED && userStatus == ALLOWED;
+    }
+}
\ No newline at end of file
diff --git a/server/mailet/mailets/src/test/java/org/apache/james/transport/matchers/IsInDropListTest.java b/server/mailet/mailets/src/test/java/org/apache/james/transport/matchers/IsInDropListTest.java
new file mode 100644
index 0000000..7880d88
--- /dev/null
+++ b/server/mailet/mailets/src/test/java/org/apache/james/transport/matchers/IsInDropListTest.java
@@ -0,0 +1,152 @@
+/****************************************************************
+ * 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.transport.matchers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.stream.Stream;
+
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.AddressException;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+import org.apache.james.droplists.memory.MemoryDropList;
+import org.apache.mailet.Mail;
+import org.apache.mailet.Matcher;
+import org.apache.mailet.base.test.FakeMail;
+import org.apache.mailet.base.test.FakeMatcherConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class IsInDropListTest {
+
+    private static Matcher matcher;
+    private static DropList dropList;
+    private DropListEntry curentDroplistEntry;
+    private static final String DENIED_SENDER = "attacker@evil.com";
+    private static final String ALLOWED_SENDER = "allowed@allowed.com";
+    private static final String OWNER_RECIPIENT = "owner@owner.com";
+    private static final String NO_OWNER_RECIPIENT = "no_owner@noowner.com";
+
+    @BeforeAll
+    public static void setUp() throws MessagingException {
+        dropList = new MemoryDropList();
+        matcher = new IsInDropList(dropList);
+        FakeMatcherConfig matcherConfig = FakeMatcherConfig.builder()
+            .matcherName("IsInDropList")
+            .build();
+        matcher.init(matcherConfig);
+    }
+
+    @AfterEach
+    public void cleanUpEach() {
+        dropList.remove(curentDroplistEntry).block();
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideParametersForTest")
+    void matchShouldMatchSenderFromDropListEmailsWhenOneRecipientAsOwner(DropListEntry dropListEntry) throws MessagingException {
+        curentDroplistEntry = dropListEntry;
+        dropList.add(curentDroplistEntry).block();
+
+        Mail mail = FakeMail.builder()
+            .name("mail")
+            .sender(DENIED_SENDER)
+            .recipient(OWNER_RECIPIENT)
+            .build();
+
+        assertThat(matcher.match(mail)).isEmpty();
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideParametersForTwoRecipientTest")
+    void matchShouldMatchSenderFromDropListEmailsWhenTwoRecipients(DropListEntry dropListEntry) throws MessagingException {
+        curentDroplistEntry = dropListEntry;
+        dropList.add(curentDroplistEntry).block();
+
+        Mail mail = FakeMail.builder()
+            .name("mail")
+            .sender(DENIED_SENDER)
+            .recipient(OWNER_RECIPIENT)
+            .recipient(NO_OWNER_RECIPIENT)
+            .build();
+
+        assertThat(matcher.match(mail)).containsExactly(new MailAddress(NO_OWNER_RECIPIENT));
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideParametersForTest")
+    void matchShouldNotMatchIfSenderNotFromDropListEmails(DropListEntry dropListEntry) throws MessagingException {
+        curentDroplistEntry = dropListEntry;
+        dropList.add(curentDroplistEntry).block();
+
+        Mail mail = FakeMail.builder()
+            .name("mail")
+            .sender(ALLOWED_SENDER)
+            .recipient(OWNER_RECIPIENT)
+            .recipient(NO_OWNER_RECIPIENT)
+            .build();
+
+        assertThat(matcher.match(mail)).contains(new MailAddress(OWNER_RECIPIENT), new MailAddress(NO_OWNER_RECIPIENT));
+    }
+
+    static Stream<DropListEntry> getDropListTestEntries() throws AddressException {
+        return Stream.of(
+            DropListEntry.builder()
+                .forAll()
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .forAll()
+                .denyDomain(new MailAddress(DENIED_SENDER).getDomain())
+                .build(),
+            DropListEntry.builder()
+                .domainOwner(new MailAddress(OWNER_RECIPIENT).getDomain())
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .domainOwner(new MailAddress(OWNER_RECIPIENT).getDomain())
+                .denyDomain(new MailAddress(DENIED_SENDER).getDomain())
+                .build(),
+            DropListEntry.builder()
+                .userOwner(new MailAddress(OWNER_RECIPIENT))
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .userOwner(new MailAddress(OWNER_RECIPIENT))
+                .denyDomain(new MailAddress(DENIED_SENDER).getDomain())
+                .build());
+    }
+
+    static Stream<Arguments> provideParametersForTest() throws AddressException {
+        return getDropListTestEntries().map(Arguments::of);
+    }
+
+    static Stream<Arguments> provideParametersForTwoRecipientTest() throws AddressException {
+        return getDropListTestEntries()
+            .filter(dropListEntry -> !dropListEntry.getOwnerScope().equals(OwnerScope.GLOBAL))
+            .map(Arguments::of);
+    }
+}
\ No newline at end of file
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DropListRoutes.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DropListRoutes.java
new file mode 100644
index 0000000..5fdb93f
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/DropListRoutes.java
@@ -0,0 +1,218 @@
+/****************************************************************
+ * 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.routes;
+
+import static org.apache.james.webadmin.Constants.SEPARATOR;
+
+import java.util.List;
+import java.util.Optional;
+
+import jakarta.inject.Inject;
+import jakarta.mail.internet.AddressException;
+
+import org.apache.commons.lang3.EnumUtils;
+import org.apache.james.core.Domain;
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DeniedEntityType;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+import org.apache.james.util.ReactorUtils;
+import org.apache.james.webadmin.Constants;
+import org.apache.james.webadmin.Routes;
+import org.apache.james.webadmin.utils.ErrorResponder;
+import org.apache.james.webadmin.utils.JsonTransformer;
+import org.apache.james.webadmin.utils.Responses;
+import org.eclipse.jetty.http.HttpStatus;
+
+import com.google.common.collect.ImmutableSet;
+
+import reactor.core.publisher.Flux;
+import spark.Request;
+import spark.Response;
+import spark.Service;
+
+public class DropListRoutes implements Routes {
+    public static final String DROP_LIST = "/droplist";
+    public static final String OWNER_SCOPE = ":ownerScope";
+    public static final String OWNER = ":owner";
+    public static final String DENIED_ENTITY = ":deniedEntity";
+    public static final String DENIED_ENTITY_TYPE = "deniedEntityType";
+
+    private final DropList dropList;
+    private final JsonTransformer jsonTransformer;
+
+    @Inject
+    public DropListRoutes(DropList dropList, JsonTransformer jsonTransformer) {
+        this.dropList = dropList;
+        this.jsonTransformer = jsonTransformer;
+    }
+
+    @Override
+    public String getBasePath() {
+        return DROP_LIST;
+    }
+
+    @Override
+    public void define(Service service) {
+        service.get(DROP_LIST + SEPARATOR + OWNER_SCOPE, this::getDropList, jsonTransformer);
+        service.get(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + OWNER, this::getDropList, jsonTransformer);
+        service.put(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + OWNER + SEPARATOR + DENIED_ENTITY, this::addDropListEntry);
+        service.put(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + DENIED_ENTITY, this::addDropListEntry);
+        service.head(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + OWNER + SEPARATOR + DENIED_ENTITY, this::dropListEntryExist);
+        service.head(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + DENIED_ENTITY, this::dropListEntryExist);
+        service.delete(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + OWNER + SEPARATOR + DENIED_ENTITY, this::removeDropListEntry);
+        service.delete(DROP_LIST + SEPARATOR + OWNER_SCOPE + SEPARATOR + DENIED_ENTITY, this::removeDropListEntry);
+    }
+
+    public ImmutableSet<String> getDropList(Request request, Response response) {
+        OwnerScope ownerScope = checkValidOwnerScope(request.params(OWNER_SCOPE));
+        String owner = Optional.ofNullable(request.params(OWNER)).orElse("");
+        Optional<DeniedEntityType> deniedEntityType = checkValidDeniedEntityType(request.queryParams(DENIED_ENTITY_TYPE));
+        if (deniedEntityType.isPresent()) {
+            return dropList.list(ownerScope, owner)
+                .filter(deniedEntry -> deniedEntry.getDeniedEntityType().equals(deniedEntityType.get()))
+                .map(DropListEntry::getDeniedEntity)
+                .collect(ImmutableSet.toImmutableSet())
+                .block();
+        } else {
+            return dropList.list(ownerScope, owner)
+                .map(DropListEntry::getDeniedEntity)
+                .collect(ImmutableSet.toImmutableSet())
+                .block();
+        }
+    }
+
+    public String addDropListEntry(Request request, Response response) {
+        OwnerScope ownerScope = checkValidOwnerScope(request.params(OWNER_SCOPE));
+        String owner = Optional.ofNullable(request.params(OWNER)).orElse("");
+        String deniedEntity = request.params(DENIED_ENTITY);
+        DropListEntry dropListEntry = getDropListEntry(ownerScope, owner, deniedEntity);
+        dropList.add(dropListEntry).block();
+        return Responses.returnNoContent(response);
+    }
+
+    public String removeDropListEntry(Request request, Response response) {
+        OwnerScope ownerScope = checkValidOwnerScope(request.params(OWNER_SCOPE));
+        String owner = Optional.ofNullable(request.params(OWNER)).orElse("");
+        String deniedEntity = request.params(DENIED_ENTITY);
+        dropList.list(ownerScope, owner)
+            .filter(dropListEntry -> dropListEntry.getDeniedEntity().equals(deniedEntity))
+            .collectList()
+            .doOnNext(this::deleteDropListEntry)
+            .block();
+        return Responses.returnNoContent(response);
+    }
+
+    public String dropListEntryExist(Request request, Response response) {
+        OwnerScope ownerScope = checkValidOwnerScope(request.params(OWNER_SCOPE));
+        String owner = Optional.ofNullable(request.params(OWNER)).orElse("");
+        String deniedEntity = request.params(DENIED_ENTITY);
+        boolean entryExists = dropList.list(ownerScope, owner)
+            .any(dropListEntry -> dropListEntry.getDeniedEntity().equals(deniedEntity))
+            .block();
+        if (entryExists) {
+            response.status(HttpStatus.NO_CONTENT_204);
+        } else {
+            response.status(HttpStatus.NOT_FOUND_404);
+        }
+        return Constants.EMPTY_BODY;
+    }
+
+    private Optional<DeniedEntityType> checkValidDeniedEntityType(String deniedEntityType) {
+        try {
+            if (deniedEntityType == null || deniedEntityType.isEmpty()) {
+                return Optional.empty();
+            } else {
+                return Optional.of(DeniedEntityType.valueOf(deniedEntityType.toUpperCase()));
+            }
+        } catch (IllegalArgumentException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid DeniedEntityType")
+                .cause(new IllegalArgumentException("DeniedEntityType '" + deniedEntityType + "' is invalid. Supported values are " +
+                    EnumUtils.getEnumList(DeniedEntityType.class)))
+                .haltError();
+        }
+    }
+
+    private DropListEntry getDropListEntry(OwnerScope ownerScope, String owner, String deniedEntity) {
+        DropListEntry.Builder dropListEntryBuilder = DropListEntry.builder();
+        switch (ownerScope) {
+            case GLOBAL -> dropListEntryBuilder = dropListEntryBuilder.forAll();
+            case DOMAIN -> dropListEntryBuilder = dropListEntryBuilder.domainOwner(checkValidDomain(owner));
+            case USER -> dropListEntryBuilder = dropListEntryBuilder.userOwner(checkValidMailAddress(owner));
+        }
+        if (deniedEntity.contains("@")) {
+            dropListEntryBuilder.denyAddress(checkValidMailAddress(deniedEntity));
+        } else {
+            dropListEntryBuilder.denyDomain(checkValidDomain(deniedEntity));
+        }
+        return dropListEntryBuilder.build();
+    }
+
+    private OwnerScope checkValidOwnerScope(String ownerScope) {
+        try {
+            return OwnerScope.valueOf(ownerScope.toUpperCase());
+        } catch (IllegalArgumentException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid OwnerScope")
+                .cause(new IllegalArgumentException("OwnerScope '" + ownerScope + "' is invalid. Supported values are " +
+                    EnumUtils.getEnumList(OwnerScope.class)))
+                .haltError();
+        }
+    }
+
+    private static MailAddress checkValidMailAddress(String address) {
+        try {
+            return new MailAddress(address);
+        } catch (AddressException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid mail address %s", address)
+                .cause(e)
+                .haltError();
+        }
+    }
+
+    private Domain checkValidDomain(String domainName) {
+        try {
+            return Domain.of(domainName);
+        } catch (IllegalArgumentException e) {
+            throw ErrorResponder.builder()
+                .statusCode(HttpStatus.BAD_REQUEST_400)
+                .type(ErrorResponder.ErrorType.INVALID_ARGUMENT)
+                .message("Invalid domain %s", domainName)
+                .cause(e)
+                .haltError();
+        }
+    }
+
+    private void deleteDropListEntry(List<DropListEntry> dropListEntries) {
+        Flux.fromIterable(dropListEntries)
+            .flatMap(dropList::remove, ReactorUtils.DEFAULT_CONCURRENCY)
+            .then()
+            .block();
+    }
+}
\ No newline at end of file
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DropListRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DropListRoutesTest.java
new file mode 100644
index 0000000..3ba3df5
--- /dev/null
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/DropListRoutesTest.java
@@ -0,0 +1,273 @@
+/****************************************************************
+ * 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.routes;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.RestAssured.when;
+import static org.apache.james.webadmin.Constants.SEPARATOR;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.is;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import jakarta.mail.internet.AddressException;
+
+import org.apache.james.core.MailAddress;
+import org.apache.james.droplists.api.DropList;
+import org.apache.james.droplists.api.DropListEntry;
+import org.apache.james.droplists.api.OwnerScope;
+import org.apache.james.droplists.memory.MemoryDropList;
+import org.apache.james.webadmin.WebAdminServer;
+import org.apache.james.webadmin.WebAdminUtils;
+import org.apache.james.webadmin.utils.ErrorResponder;
+import org.apache.james.webadmin.utils.JsonTransformer;
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+
+class DropListRoutesTest {
+    private static final String DENIED_SENDER = "attacker@evil.com";
+    private static final String OWNER_RECIPIENT = "owner@owner.com";
+
+    private WebAdminServer webAdminServer;
+    private DropList dropList;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        dropList = new MemoryDropList();
+        DropListRoutes dropListRoutes = new DropListRoutes(dropList, new JsonTransformer());
+        this.webAdminServer = WebAdminUtils.createWebAdminServer(dropListRoutes).start();
+        RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer).build();
+        RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
+        getDropListTestEntries().forEach(entry -> dropList.add(entry).block());
+    }
+
+    @AfterEach
+    void tearDown() throws AddressException {
+        webAdminServer.destroy();
+        getDropListTestEntries().forEach(entry -> dropList.remove(entry).block());
+    }
+
+    @ParameterizedTest(name = "{index} Owner: {0}")
+    @ValueSource(strings = {
+        "global",
+        "domain/owner.com",
+        "user/owner@owner.com"})
+    void shouldGetFullDropList(String pathParam) {
+        when()
+            .get(DropListRoutes.DROP_LIST + SEPARATOR + pathParam)
+        .then()
+            .statusCode(HttpStatus.OK_200)
+            .contentType(ContentType.JSON)
+            .body(".", containsInAnyOrder("attacker@evil.com", "evil.com"));
+    }
+
+    @ParameterizedTest(name = "{index} Owner: {0}")
+    @ValueSource(strings = {
+        "unknown",
+        "unknown/owner.com",
+        "unknown/owner@owner.com"})
+    void shouldHandleWhenGetDropListWithInvalidOwnerScope(String pathParam) {
+        when()
+            .get(DropListRoutes.DROP_LIST + SEPARATOR + pathParam)
+        .then()
+            .statusCode(HttpStatus.BAD_REQUEST_400)
+            .contentType(ContentType.JSON)
+            .body("statusCode", is(HttpStatus.BAD_REQUEST_400))
+            .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+            .body("message", is("Invalid OwnerScope"))
+            .body("details", is("OwnerScope 'unknown' is invalid. Supported values are [GLOBAL, DOMAIN, USER]"));
+    }
+
+    @ParameterizedTest(name = "{index} Owner: {0}, DeniedEntityType: {1}")
+    @CsvSource(value = {
+        "global, domain, evil.com",
+        "global, address, attacker@evil.com",
+        "domain/owner.com, domain, evil.com",
+        "domain/owner.com, address, attacker@evil.com",
+        "user/owner@owner.com, domain, evil.com",
+        "user/owner@owner.com, address, attacker@evil.com"})
+    void shouldGetDropListWithQueryParams(String pathParam, String queryParam, String expected) {
+        given()
+            .queryParam("deniedEntityType", queryParam)
+        .when()
+            .get(DropListRoutes.DROP_LIST + SEPARATOR + pathParam)
+        .then()
+            .statusCode(HttpStatus.OK_200)
+            .contentType(ContentType.JSON)
+            .body(".", containsInAnyOrder(expected));
+    }
+
+    @Test
+    void shouldHandleInvalidDeniedEntityType() {
+        given()
+            .queryParam("deniedEntityType", "unknown")
+        .when()
+            .get(DropListRoutes.DROP_LIST + SEPARATOR + "global")
+        .then()
+            .statusCode(HttpStatus.BAD_REQUEST_400)
+            .contentType(ContentType.JSON)
+            .body("statusCode", is(HttpStatus.BAD_REQUEST_400))
+            .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+            .body("message", is("Invalid DeniedEntityType"))
+            .body("details", is("DeniedEntityType 'unknown' is invalid. Supported values are [ADDRESS, DOMAIN]"));
+    }
+
+    @ParameterizedTest(name = "{index} OwnerScope: {0}, Owner: {1}, DeniedEntity: {2}")
+    @CsvSource(value = {
+        "global, , devil.com",
+        "global, , bad_guy@crime.com",
+        "domain, owner.com, devil.com",
+        "domain, owner.com, bad_guy@crime.com",
+        "user, owner@owner.com, devil.com",
+        "user, owner@owner.com, bad_guy@crime.com"})
+    void shouldAddDropListEntry(String ownerScope, String owner, String newDeniedEntity) {
+        when()
+            .put(DropListRoutes.DROP_LIST + SEPARATOR + ownerScope + SEPARATOR + owner + SEPARATOR + newDeniedEntity)
+        .then()
+            .statusCode(HttpStatus.NO_CONTENT_204);
+
+        assertThat(getResultDropList(ownerScope, owner)).contains(newDeniedEntity);
+    }
+
+    @ParameterizedTest(name = "{index} OwnerScope: {0}, Owner: {1}, DeniedEntity: {2}")
+    @CsvSource(value = {
+        "global, , devil..com, Invalid domain devil..com",
+        "global, , bad_guy@@crime.com, Invalid mail address bad_guy@@crime.com",
+        "domain, owner.com, devil..com, Invalid domain devil..com",
+        "domain, owner.com, bad_guy@@crime.com, Invalid mail address bad_guy@@crime.com",
+        "user, owner@owner.com, devil..com, Invalid domain devil..com",
+        "user, owner@owner.com, bad_guy@@crime.com, Invalid mail address bad_guy@@crime.com"})
+    void shouldFailWhenAddInvalidDeniedEntity(String ownerScope, String owner, String newDeniedEntity, String message) {
+        when()
+            .put(DropListRoutes.DROP_LIST + SEPARATOR + ownerScope + SEPARATOR + owner + SEPARATOR + newDeniedEntity)
+        .then()
+            .statusCode(HttpStatus.BAD_REQUEST_400)
+            .contentType(ContentType.JSON)
+            .body("statusCode", is(HttpStatus.BAD_REQUEST_400))
+            .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+            .body("message", is(message));
+    }
+
+    @ParameterizedTest(name = "{index} Path: {0}")
+    @CsvSource(value = {
+        "/global/evil.com",
+        "/global/attacker@evil.com",
+        "/domain/owner.com/evil.com",
+        "/domain/owner.com/attacker@evil.com",
+        "/user/owner@owner.com/evil.com",
+        "/user/owner@owner.com/attacker@evil.com"})
+    void headShouldReturnNoContentWhenDomainDeniedEntityExists(String path) {
+        when()
+            .head(DropListRoutes.DROP_LIST + path)
+        .then()
+            .statusCode(HttpStatus.NO_CONTENT_204);
+    }
+
+    @ParameterizedTest(name = "{index} Path: {0}")
+    @CsvSource(value = {
+        "global/devil.com",
+        "global/bad_guy@crime.com",
+        "/domain/owner.com/devil.com",
+        "/domain/owner.com/bad_guy@crime.com",
+        "/user/owner@owner.com/devil.com",
+        "/user/owner@owner.com/bad_guy@crime.com"})
+    void headShouldReturnNotFoundWhenDomainDeniedEntityNotExists(String path) {
+        when()
+            .head(DropListRoutes.DROP_LIST + path)
+        .then()
+            .statusCode(HttpStatus.NOT_FOUND_404);
+    }
+
+    @ParameterizedTest(name = "{index} Path: {3}")
+    @CsvSource(value = {
+        "global, , evil.com, /global/evil.com",
+        "global, , attacker@evil.com, /global/attacker@evil.com",
+        "domain, owner.com, evil.com, /domain/owner.com/evil.com",
+        "domain, owner.com, attacker@evil.com, /domain/owner.com/attacker@evil.com",
+        "user, owner@owner.com, evil.com, /user/owner@owner.com/evil.com",
+        "user, owner@owner.com, attacker@evil.com, /user/owner@owner.com/attacker@evil.com"})
+    void deleteShouldReturnNoContent(String ownerScope, String owner, String deniedEntity, String path) {
+        given()
+            .delete(DropListRoutes.DROP_LIST + path)
+        .then()
+            .statusCode(HttpStatus.NO_CONTENT_204);
+
+        assertThat(getResultDropList(ownerScope, owner)).doesNotContain(deniedEntity);
+    }
+
+    @Test
+    void deleteShouldReturnNotFoundWhenUsedWithEmptyEntry() {
+        given()
+            .delete(SEPARATOR)
+        .then()
+            .statusCode(HttpStatus.NOT_FOUND_404);
+    }
+
+    static Stream<DropListEntry> getDropListTestEntries() throws AddressException {
+        return Stream.of(
+            DropListEntry.builder()
+                .forAll()
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .forAll()
+                .denyDomain(new MailAddress(DENIED_SENDER).getDomain())
+                .build(),
+            DropListEntry.builder()
+                .forAll()
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .domainOwner(new MailAddress(OWNER_RECIPIENT).getDomain())
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .domainOwner(new MailAddress(OWNER_RECIPIENT).getDomain())
+                .denyDomain(new MailAddress(DENIED_SENDER).getDomain())
+                .build(),
+            DropListEntry.builder()
+                .userOwner(new MailAddress(OWNER_RECIPIENT))
+                .denyAddress(new MailAddress(DENIED_SENDER))
+                .build(),
+            DropListEntry.builder()
+                .userOwner(new MailAddress(OWNER_RECIPIENT))
+                .denyDomain(new MailAddress(DENIED_SENDER).getDomain())
+                .build());
+    }
+
+    private List<String> getResultDropList(String ownerScope, String owner) {
+        return dropList.list(OwnerScope.valueOf(ownerScope.toUpperCase()),
+                Optional.ofNullable(owner).orElse(""))
+            .map(DropListEntry::getDeniedEntity)
+            .collectList()
+            .block();
+    }
+}
\ No newline at end of file