You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by rc...@apache.org on 2024/04/23 03:17:47 UTC

(james-project) branch droplist updated: JAMES-3946 add a Cassandra implementation of the DropList (#2210)

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

rcordier pushed a commit to branch droplist
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/droplist by this push:
     new 6873e67999 JAMES-3946 add a Cassandra implementation of the DropList (#2210)
6873e67999 is described below

commit 6873e679992e7845691cf625136d2fd8a2782a31
Author: Maksim <85...@users.noreply.github.com>
AuthorDate: Tue Apr 23 06:17:42 2024 +0300

    JAMES-3946 add a Cassandra implementation of the DropList (#2210)
---
 .../james/droplists/api/DropListContract.java      |  43 +++---
 .../droplists/cassandra/CassandraDropList.java     |  68 ++++++++++
 .../droplists/cassandra/CassandraDropListDAO.java  | 147 +++++++++++++++++++++
 .../cassandra/CassandraDropListModule.java         |  35 +++++
 .../cassandra/tables/CassandraDropListTable.java   |  30 +++++
 .../droplists/cassandra/CassandraDropListTest.java |  43 ++++++
 .../james/droplists/memory/MemoryDropList.java     |   8 +-
 7 files changed, 342 insertions(+), 32 deletions(-)

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
index a4993ff973..bdb21c79e1 100644
--- 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
@@ -40,32 +40,21 @@ public interface DropListContract {
 
     DropList dropList();
 
-    @Test
-    default void shouldAddEntry() throws AddressException {
-        DropListEntry dropListEntry = DropListEntry.builder()
-            .forAll()
-            .denyAddress(new MailAddress("denied@denied.com"))
-            .build();
-
-        Mono<Void> result = dropList().add(dropListEntry);
+    @ParameterizedTest(name = "{index} {0}")
+    @MethodSource("provideParametersForGetEntryListTest")
+    default void shouldAddEntry(DropListEntry dropListEntry) throws AddressException {
+        dropList().add(dropListEntry).block();
 
-        assertThat(dropList().list(GLOBAL, dropListEntry.getOwner()).collectList().block().size()).isEqualTo(1);
-        assertThat(result).isEqualTo(Mono.empty());
+        assertThat(dropList().list(dropListEntry.getOwnerScope(), dropListEntry.getOwner()).collectList().block().size()).isEqualTo(1);
     }
 
-    @Test
-    default void shouldRemoveEntry() throws AddressException {
-        DropListEntry dropListEntry = DropListEntry.builder()
-            .forAll()
-            .denyAddress(new MailAddress("denied@denied.com"))
-            .build();
-
-        dropList().add(dropListEntry);
-
-        Mono<Void> result = dropList().remove(dropListEntry);
+    @ParameterizedTest(name = "{index} {0}")
+    @MethodSource("provideParametersForGetEntryListTest")
+    default void shouldRemoveEntry(DropListEntry dropListEntry) throws AddressException {
+        dropList().add(dropListEntry).block();
+        dropList().remove(dropListEntry).block();
 
-        assertThat(dropList().list(GLOBAL, dropListEntry.getOwner()).collectList().block().size()).isZero();
-        assertThat(result).isEqualTo(Mono.empty());
+        assertThat(dropList().list(dropListEntry.getOwnerScope(), dropListEntry.getOwner()).collectList().block().size()).isZero();
     }
 
     @Test
@@ -113,7 +102,7 @@ public interface DropListContract {
     @ParameterizedTest(name = "{index} {0}")
     @MethodSource("provideParametersForGetEntryListTest")
     default void shouldGetEntryListForSpecifiedScopeAndOwner(DropListEntry dropListEntry) {
-        dropList().add(dropListEntry);
+        dropList().add(dropListEntry).block();
 
         Flux<DropListEntry> result = dropList().list(dropListEntry.getOwnerScope(), dropListEntry.getOwner());
 
@@ -121,20 +110,20 @@ public interface DropListContract {
     }
 
 
-    @ParameterizedTest(name = "{index} {0}")
+    @ParameterizedTest(name = "{index} {0}, sender: {1}")
     @MethodSource("provideParametersForReturnAllowedTest")
     default void shouldReturnAllowed(DropListEntry dropListEntry, MailAddress senderMailAddress) {
-        dropList().add(dropListEntry);
+        dropList().add(dropListEntry).block();
 
         Mono<DropList.Status> result = dropList().query(dropListEntry.getOwnerScope(), dropListEntry.getOwner(), senderMailAddress);
 
         assertThat(result.block()).isEqualTo(DropList.Status.ALLOWED);
     }
 
-    @ParameterizedTest(name = "{index} {0}")
+    @ParameterizedTest(name = "{index} {0}, sender: {1}")
     @MethodSource("provideParametersForReturnBlockedTest")
     default void shouldReturnBlocked(DropListEntry dropListEntry, MailAddress senderMailAddress) {
-        dropList().add(dropListEntry);
+        dropList().add(dropListEntry).block();
 
         Mono<DropList.Status> result = dropList().query(dropListEntry.getOwnerScope(), dropListEntry.getOwner(), senderMailAddress);
 
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 0000000000..98c5fd074a
--- /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 0000000000..be32a6f7bf
--- /dev/null
+++ b/server/data/data-cassandra/src/main/java/org/apache/james/droplists/cassandra/CassandraDropListDAO.java
@@ -0,0 +1,147 @@
+/****************************************************************
+ * 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;
+
+    @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());
+    }
+
+    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) {
+        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 0000000000..b555c1c2a0
--- /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 0000000000..5a42a98fec
--- /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 0000000000..0871d00df7
--- /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
index b413d4f919..db803fe358 100644
--- 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
@@ -43,8 +43,7 @@ public class MemoryDropList implements DropList {
         Preconditions.checkArgument(entry != null);
         OwnerScope ownerScope = entry.getOwnerScope();
         Multimap<OwnerScope, DropListEntry> selectedDropList = getDropListByScope(ownerScope);
-        selectedDropList.put(ownerScope, entry);
-        return Mono.empty();
+        return Mono.fromRunnable(() -> selectedDropList.put(ownerScope, entry));
     }
 
     @Override
@@ -52,8 +51,7 @@ public class MemoryDropList implements DropList {
         Preconditions.checkArgument(entry != null);
         OwnerScope ownerScope = entry.getOwnerScope();
         Multimap<OwnerScope, DropListEntry> selectedDropList = getDropListByScope(ownerScope);
-        selectedDropList.remove(ownerScope, entry);
-        return Mono.empty();
+        return Mono.fromRunnable(() -> selectedDropList.remove(ownerScope, entry));
     }
 
     @Override
@@ -94,4 +92,4 @@ public class MemoryDropList implements DropList {
 
         return entry.getDeniedEntity().equals(entityFromSender);
     }
-}
+}
\ No newline at end of file


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