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 — 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