You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2022/06/03 08:41:27 UTC
[james-project] 01/03: JAMES-3756 Webadmin API for delegation
This is an automated email from the ASF dual-hosted git repository.
btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 2cd086a6bfa2612da6a0929b74873fc9579a0eef
Author: Quan Tran <hq...@linagora.com>
AuthorDate: Thu May 26 17:13:55 2022 +0700
JAMES-3756 Webadmin API for delegation
---
.../apache/james/webadmin/routes/UserRoutes.java | 113 ++++++++-
.../james/webadmin/routes/UserRoutesTest.java | 254 ++++++++++++++++++++-
2 files changed, 364 insertions(+), 3 deletions(-)
diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/UserRoutes.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/UserRoutes.java
index f6117ff8c5..90c2f9c59f 100644
--- a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/UserRoutes.java
+++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/UserRoutes.java
@@ -32,6 +32,7 @@ import org.apache.james.rrt.api.CanSendFrom;
import org.apache.james.rrt.api.RecipientRewriteTable;
import org.apache.james.rrt.api.RecipientRewriteTableException;
import org.apache.james.user.api.AlreadyExistInUsersRepositoryException;
+import org.apache.james.user.api.DelegationStore;
import org.apache.james.user.api.InvalidUsernameException;
import org.apache.james.user.api.UsersRepositoryException;
import org.apache.james.webadmin.Constants;
@@ -51,6 +52,8 @@ import org.slf4j.LoggerFactory;
import com.google.common.collect.ImmutableList;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
import spark.HaltException;
import spark.Request;
import spark.Response;
@@ -59,27 +62,31 @@ import spark.Service;
public class UserRoutes implements Routes {
private static final String USER_NAME = ":userName";
+ private static final String DELEGATED_USER_NAME = ":delegatedUserName";
private static final Logger LOGGER = LoggerFactory.getLogger(UserRoutes.class);
public static final String USERS = "/users";
private static final String FORCE_PARAM = "force";
private static final String VERIFY = "verify";
+ private static final String AUTHORIZED_USERS = "authorizedUsers";
private final UserService userService;
private final JsonTransformer jsonTransformer;
private final JsonExtractor<VerifyUserRequest> jsonExtractorVerify;
private final CanSendFrom canSendFrom;
private final JsonExtractor<AddUserRequest> jsonExtractor;
+ private final DelegationStore delegationStore;
private Service service;
@Inject
- public UserRoutes(UserService userService, CanSendFrom canSendFrom, JsonTransformer jsonTransformer) {
+ public UserRoutes(UserService userService, CanSendFrom canSendFrom, JsonTransformer jsonTransformer, DelegationStore delegationStore) {
this.userService = userService;
this.jsonTransformer = jsonTransformer;
this.canSendFrom = canSendFrom;
this.jsonExtractor = new JsonExtractor<>(AddUserRequest.class);
this.jsonExtractorVerify = new JsonExtractor<>(VerifyUserRequest.class);
+ this.delegationStore = delegationStore;
}
@Override
@@ -102,6 +109,30 @@ public class UserRoutes implements Routes {
defineUserExist();
defineVerifyUsers();
+
+ getDelegatedUsers();
+
+ clearAllDelegatedUsers();
+
+ addDelegatedUser();
+
+ removeDelegatedUser();
+ }
+
+ public void getDelegatedUsers() {
+ service.get(USERS + SEPARATOR + USER_NAME + SEPARATOR + AUTHORIZED_USERS, this::getAuthorizedUsers, jsonTransformer);
+ }
+
+ public void clearAllDelegatedUsers() {
+ service.delete(USERS + SEPARATOR + USER_NAME + SEPARATOR + AUTHORIZED_USERS, this::clearAllAuthorizedUsers);
+ }
+
+ public void addDelegatedUser() {
+ service.put(USERS + SEPARATOR + USER_NAME + SEPARATOR + AUTHORIZED_USERS + SEPARATOR + DELEGATED_USER_NAME, this::addAuthorizedUser);
+ }
+
+ public void removeDelegatedUser() {
+ service.delete(USERS + SEPARATOR + USER_NAME + SEPARATOR + AUTHORIZED_USERS + SEPARATOR + DELEGATED_USER_NAME, this::removeAuthorizedUser);
}
public void defineVerifyUsers() {
@@ -224,6 +255,82 @@ public class UserRoutes implements Routes {
}
}
+ private List<String> getAuthorizedUsers(Request request, Response response) throws UsersRepositoryException {
+ Username baseUser = extractUsername(request);
+
+ if (userService.userExists(baseUser)) {
+ return Flux.from(delegationStore.authorizedUsers(baseUser))
+ .map(Username::asString)
+ .collectList()
+ .block();
+ } else {
+ throw ErrorResponder.builder()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .type(ErrorType.NOT_FOUND)
+ .message(String.format("User '%s' does not exist", baseUser.asString()))
+ .haltError();
+ }
+ }
+
+ private String clearAllAuthorizedUsers(Request request, Response response) throws UsersRepositoryException {
+ Username baseUser = extractUsername(request);
+
+ if (userService.userExists(baseUser)) {
+ Mono.from(delegationStore.clear(baseUser)).block();
+ return Constants.EMPTY_BODY;
+ } else {
+ throw ErrorResponder.builder()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .type(ErrorType.NOT_FOUND)
+ .message(String.format("User '%s' does not exist", baseUser.asString()))
+ .haltError();
+ }
+ }
+
+ private String addAuthorizedUser(Request request, Response response) throws UsersRepositoryException {
+ Username baseUser = extractUsername(request);
+ Username delegatedUser = extractDelegatedUsername(request);
+
+ if (!userService.userExists(baseUser)) {
+ throw ErrorResponder.builder()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .type(ErrorType.NOT_FOUND)
+ .message(String.format("User '%s' does not exist", baseUser.asString()))
+ .haltError();
+ } else if (!userService.userExists(delegatedUser)) {
+ throw ErrorResponder.builder()
+ .statusCode(HttpStatus.BAD_REQUEST_400)
+ .type(ErrorType.INVALID_ARGUMENT)
+ .message(String.format("Delegated user '%s' does not exist", delegatedUser.asString()))
+ .haltError();
+ } else {
+ Mono.from(delegationStore.addAuthorizedUser(baseUser, delegatedUser)).block();
+ return Constants.EMPTY_BODY;
+ }
+ }
+
+ private String removeAuthorizedUser(Request request, Response response) throws UsersRepositoryException {
+ Username baseUser = extractUsername(request);
+ Username delegatedUser = extractDelegatedUsername(request);
+
+ if (!userService.userExists(baseUser)) {
+ throw ErrorResponder.builder()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .type(ErrorType.NOT_FOUND)
+ .message(String.format("User '%s' does not exist", baseUser.asString()))
+ .haltError();
+ } else if (!userService.userExists(delegatedUser)) {
+ throw ErrorResponder.builder()
+ .statusCode(HttpStatus.BAD_REQUEST_400)
+ .type(ErrorType.INVALID_ARGUMENT)
+ .message(String.format("Delegated user '%s' does not exist", delegatedUser.asString()))
+ .haltError();
+ } else {
+ Mono.from(delegationStore.removeAuthorizedUser(baseUser, delegatedUser)).block();
+ return Constants.EMPTY_BODY;
+ }
+ }
+
private List<String> allowedFromHeaders(Request request, Response response) {
Username username = extractUsername(request);
@@ -257,4 +364,8 @@ public class UserRoutes implements Routes {
return Username.of(request.params(USER_NAME));
}
+ private Username extractDelegatedUsername(Request request) {
+ return Username.of(request.params(DELEGATED_USER_NAME));
+ }
+
}
\ No newline at end of file
diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/UserRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/UserRoutesTest.java
index bb6514aa0f..ffed30a326 100644
--- a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/UserRoutesTest.java
+++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/UserRoutesTest.java
@@ -52,9 +52,11 @@ import org.apache.james.rrt.lib.AliasReverseResolverImpl;
import org.apache.james.rrt.lib.CanSendFromImpl;
import org.apache.james.rrt.lib.MappingSource;
import org.apache.james.rrt.memory.MemoryRecipientRewriteTable;
+import org.apache.james.user.api.DelegationStore;
import org.apache.james.user.api.UsersRepository;
import org.apache.james.user.api.UsersRepositoryException;
import org.apache.james.user.api.model.User;
+import org.apache.james.user.memory.MemoryDelegationStore;
import org.apache.james.user.memory.MemoryUsersRepository;
import org.apache.james.webadmin.WebAdminServer;
import org.apache.james.webadmin.WebAdminUtils;
@@ -80,9 +82,16 @@ import com.google.common.collect.ImmutableMap;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
class UserRoutesTest {
+ private static final String GET_DELEGATED_USERS_PATH = "/%s/authorizedUsers";
+ private static final String CLEAR_DELEGATED_USERS_PATH = "/%s/authorizedUsers";
+ private static final String ADD_DELEGATED_USER_PATH = "/%s/authorizedUsers/%s";
+ private static final String REMOVE_DELEGATED_USER_PATH = "/%s/authorizedUsers/%s";
+
private static class UserRoutesExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver {
static UserRoutesExtension withVirtualHosting() throws DomainListException {
@@ -106,6 +115,7 @@ class UserRoutesTest {
final MemoryRecipientRewriteTable recipientRewriteTable;
final AliasReverseResolver aliasReverseResolver;
final CanSendFrom canSendFrom;
+ final MemoryDelegationStore delegationStore;
WebAdminServer webAdminServer;
@@ -123,6 +133,7 @@ class UserRoutesTest {
this.usersRepository.setValidator(validator);
recipientRewriteTable.setUsersRepository(usersRepository);
recipientRewriteTable.setUserEntityValidator(validator);
+ this.delegationStore = new MemoryDelegationStore();
}
@Override
@@ -139,7 +150,8 @@ class UserRoutesTest {
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
Class<?> parameterType = parameterContext.getParameter().getType();
return parameterType.isAssignableFrom(UsersRepository.class)
- || parameterType.isAssignableFrom(RecipientRewriteTable.class);
+ || parameterType.isAssignableFrom(RecipientRewriteTable.class)
+ || parameterType.isAssignableFrom(DelegationStore.class);
}
@Override
@@ -151,11 +163,15 @@ class UserRoutesTest {
if (parameterType.isAssignableFrom(RecipientRewriteTable.class)) {
return recipientRewriteTable;
}
+ if (parameterType.isAssignableFrom(DelegationStore.class)) {
+ return delegationStore;
+ }
throw new RuntimeException("Unknown parameter type: " + parameterType);
}
private WebAdminServer startServer(UsersRepository usersRepository) {
- WebAdminServer server = WebAdminUtils.createWebAdminServer(new UserRoutes(new UserService(usersRepository), canSendFrom, new JsonTransformer()))
+ WebAdminServer server = WebAdminUtils.createWebAdminServer(new UserRoutes(new UserService(usersRepository), canSendFrom, new JsonTransformer(),
+ delegationStore))
.start();
RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(server)
@@ -172,6 +188,237 @@ class UserRoutesTest {
interface NormalBehaviourContract {
+ @Test
+ default void getDelegatedUsersShouldReturnEmptyByDefault(UsersRepository usersRepository) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+
+ List<Map<String, String>> delegatedUserList = when()
+ .get(String.format(GET_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".");
+
+ assertThat(delegatedUserList).isEmpty();
+ }
+
+ @Test
+ default void getDelegatedUsersShouldReturnAddedUsers(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, BOB)).block();
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, ANDRE)).block();
+
+ List<String> delegatedUserList = when()
+ .get(String.format(GET_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".");
+
+ assertThat(delegatedUserList).containsOnly(ANDRE.asString(), BOB.asString());
+ }
+
+ @Test
+ default void getDelegatedUsersShouldReturn404NotFoundWhenBaseUserIsNotExisted() {
+ when()
+ .get(String.format(GET_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .body("statusCode", is(HttpStatus.NOT_FOUND_404))
+ .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType()))
+ .body("message", is(String.format("User '%s' does not exist", ALICE.asString())));
+ }
+
+ @Test
+ default void addDelegatedUserShouldSucceed(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+ when(usersRepository.contains(BOB)).thenReturn(true);
+
+ when()
+ .put(String.format(ADD_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).containsOnly(BOB);
+ }
+
+ @Test
+ default void addDelegatedUserShouldReturn404NotFoundWhenBaseUserIsNotExisted() {
+ when()
+ .put(String.format(ADD_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .body("statusCode", is(HttpStatus.NOT_FOUND_404))
+ .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType()))
+ .body("message", is(String.format("User '%s' does not exist", ALICE.asString())));
+ }
+
+ @Test
+ default void addDelegatedUserShouldReturn400BadRequestWhenDelegatedUserIsNotExisted(UsersRepository usersRepository) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+
+ when()
+ .put(String.format(ADD_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.BAD_REQUEST_400)
+ .body("statusCode", is(HttpStatus.BAD_REQUEST_400))
+ .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+ .body("message", is(String.format("Delegated user '%s' does not exist", BOB.asString())));
+ }
+
+ @Test
+ default void removeDelegatedUserShouldRemoveThatDelegatedUser(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+ when(usersRepository.contains(BOB)).thenReturn(true);
+ when(usersRepository.contains(ANDRE)).thenReturn(true);
+
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, BOB)).block();
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, ANDRE)).block();
+
+ when()
+ .delete(String.format(REMOVE_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).containsOnly(ANDRE);
+ }
+
+ @Test
+ default void removeDelegatedUserShouldSucceedWhenEmptyAddedUsers(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+ when(usersRepository.contains(BOB)).thenReturn(true);
+
+ when()
+ .delete(String.format(REMOVE_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).isEmpty();
+ }
+
+ @Test
+ default void removeDelegatedUserShouldReturn404NotFoundWhenBaseUserIsNotExisted() {
+ when()
+ .delete(String.format(REMOVE_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .body("statusCode", is(HttpStatus.NOT_FOUND_404))
+ .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType()))
+ .body("message", is(String.format("User '%s' does not exist", ALICE.asString())));
+ }
+
+ @Test
+ default void removeDelegatedUserShouldReturn400BadRequestWhenDelegatedUserIsNotExisted(UsersRepository usersRepository) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+
+ when()
+ .delete(String.format(REMOVE_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.BAD_REQUEST_400)
+ .body("statusCode", is(HttpStatus.BAD_REQUEST_400))
+ .body("type", is(ErrorResponder.ErrorType.INVALID_ARGUMENT.getType()))
+ .body("message", is(String.format("Delegated user '%s' does not exist", BOB.asString())));
+ }
+
+
+ @Test
+ default void removeDelegatedUserShouldBeIdempotent(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+ when(usersRepository.contains(BOB)).thenReturn(true);
+
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, BOB)).block();
+
+ when()
+ .delete(String.format(REMOVE_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ when()
+ .delete(String.format(REMOVE_DELEGATED_USER_PATH, ALICE.asString(), BOB.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).isEmpty();
+ }
+
+ @Test
+ default void clearAllDelegatedUsersShouldSucceedWhenEmptyAddedUsers(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+
+ when()
+ .delete(String.format(CLEAR_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).isEmpty();
+ }
+
+ @Test
+ default void clearAllDelegatedUsersShouldBeIdempotent(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+ when(usersRepository.contains(BOB)).thenReturn(true);
+ when(usersRepository.contains(ANDRE)).thenReturn(true);
+
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, BOB)).block();
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, ANDRE)).block();
+
+ when()
+ .delete(String.format(CLEAR_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ when()
+ .delete(String.format(CLEAR_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).isEmpty();
+ }
+
+ @Test
+ default void clearAllDelegatedUsersShouldReturn404NotFoundWhenBaseUserIsNotExisted() {
+ when()
+ .delete(String.format(CLEAR_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.NOT_FOUND_404)
+ .body("statusCode", is(HttpStatus.NOT_FOUND_404))
+ .body("type", is(ErrorResponder.ErrorType.NOT_FOUND.getType()))
+ .body("message", is(String.format("User '%s' does not exist", ALICE.asString())));
+ }
+
+ @Test
+ default void clearAllDelegatedUsersShouldClearAllAddedUsers(UsersRepository usersRepository, DelegationStore delegationStore) throws UsersRepositoryException {
+ when(usersRepository.contains(ALICE)).thenReturn(true);
+ when(usersRepository.contains(BOB)).thenReturn(true);
+ when(usersRepository.contains(ANDRE)).thenReturn(true);
+
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, BOB)).block();
+ Mono.from(delegationStore.addAuthorizedUser(ALICE, ANDRE)).block();
+
+ when()
+ .delete(String.format(CLEAR_DELEGATED_USERS_PATH, ALICE.asString()))
+ .then()
+ .statusCode(HttpStatus.OK_200)
+ .contentType(ContentType.JSON);
+
+ assertThat(Flux.from(delegationStore.authorizedUsers(ALICE)).collectList().block()).isEmpty();
+ }
+
@Test
default void getUsersShouldBeEmptyByDefault() {
List<Map<String, String>> users =
@@ -527,6 +774,9 @@ class UserRoutesTest {
Username.fromLocalPartWithDomain(USERNAME_WITHOUT_DOMAIN.asString(), DOMAIN);
private static final Username OTHER_USERNAME_WITH_DOMAIN =
Username.fromLocalPartWithDomain("other", DOMAIN);
+ private static final Username ALICE = Username.fromLocalPartWithDomain("alice", DOMAIN);
+ private static final Username BOB = Username.fromLocalPartWithDomain("bob", DOMAIN);
+ private static final Username ANDRE = Username.fromLocalPartWithDomain("andre", DOMAIN);
private static final String PASSWORD = "password";
@Nested
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org