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
The following commit(s) were added to refs/heads/master by this push: new 0bfb7d760a JAMES-3830 Implement JMAP Quota/get (#1242) 0bfb7d760a is described below commit 0bfb7d760a67dd07353824e2c94d935744c14739 Author: vttran <vtt...@linagora.com> AuthorDate: Sat Oct 15 13:42:07 2022 +0700 JAMES-3830 Implement JMAP Quota/get (#1242) --- .../org/apache/james/mailbox/probe/QuotaProbe.java | 5 + .../org/apache/james/modules/QuotaProbesImpl.java | 11 + .../james/jmap/rfc8621/RFC8621MethodsModule.java | 2 + .../distributed/DistributedQuotaGetMethodTest.java | 55 ++ .../rfc8621/contract/QuotaGetMethodContract.scala | 906 +++++++++++++++++++++ .../rfc8621/memory/MemoryQuotaGetMethodTest.java | 44 + .../apache/james/jmap/json/QuotaSerializer.scala | 73 ++ .../scala/org/apache/james/jmap/mail/Quotas.scala | 136 +++- .../apache/james/jmap/method/QuotaGetMethod.scala | 131 +++ .../james/jmap/json/QuotaSerializerTest.scala | 222 +++++ 10 files changed, 1582 insertions(+), 3 deletions(-) diff --git a/mailbox/api/src/main/java/org/apache/james/mailbox/probe/QuotaProbe.java b/mailbox/api/src/main/java/org/apache/james/mailbox/probe/QuotaProbe.java index b3fa677b90..45479bfa02 100644 --- a/mailbox/api/src/main/java/org/apache/james/mailbox/probe/QuotaProbe.java +++ b/mailbox/api/src/main/java/org/apache/james/mailbox/probe/QuotaProbe.java @@ -21,6 +21,7 @@ package org.apache.james.mailbox.probe; import java.util.Optional; +import org.apache.james.core.Domain; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaCountUsage; import org.apache.james.core.quota.QuotaSizeLimit; @@ -54,4 +55,8 @@ public interface QuotaProbe { void setGlobalMaxStorage(QuotaSizeLimit maxGlobalSize) throws MailboxException; + void setDomainMaxMessage(Domain domain, QuotaCountLimit count) throws MailboxException; + + void setDomainMaxStorage(Domain domain, QuotaSizeLimit size) throws MailboxException; + } \ No newline at end of file diff --git a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/QuotaProbesImpl.java b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/QuotaProbesImpl.java index 58fa279e43..0d97356051 100644 --- a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/QuotaProbesImpl.java +++ b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/QuotaProbesImpl.java @@ -23,6 +23,7 @@ import java.util.Optional; import javax.inject.Inject; +import org.apache.james.core.Domain; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaCountUsage; import org.apache.james.core.quota.QuotaSizeLimit; @@ -104,4 +105,14 @@ public class QuotaProbesImpl implements QuotaProbe, GuiceProbe { public void setGlobalMaxStorage(QuotaSizeLimit maxGlobalSize) throws MailboxException { maxQuotaManager.setGlobalMaxStorage(maxGlobalSize); } + + @Override + public void setDomainMaxMessage(Domain domain, QuotaCountLimit count) throws MailboxException { + maxQuotaManager.setDomainMaxMessage(domain, count); + } + + @Override + public void setDomainMaxStorage(Domain domain, QuotaSizeLimit size) throws MailboxException { + maxQuotaManager.setDomainMaxStorage(domain, size); + } } diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java index 5afc4ad357..fbfe7c036e 100644 --- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java +++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java @@ -64,6 +64,7 @@ import org.apache.james.jmap.method.MailboxSetMethod; import org.apache.james.jmap.method.Method; import org.apache.james.jmap.method.PushSubscriptionGetMethod; import org.apache.james.jmap.method.PushSubscriptionSetMethod; +import org.apache.james.jmap.method.QuotaGetMethod; import org.apache.james.jmap.method.SystemZoneIdProvider; import org.apache.james.jmap.method.ThreadChangesMethod; import org.apache.james.jmap.method.ThreadGetMethod; @@ -142,6 +143,7 @@ public class RFC8621MethodsModule extends AbstractModule { methods.addBinding().to(MDNSendMethod.class); methods.addBinding().to(PushSubscriptionGetMethod.class); methods.addBinding().to(PushSubscriptionSetMethod.class); + methods.addBinding().to(QuotaGetMethod.class); methods.addBinding().to(ThreadChangesMethod.class); methods.addBinding().to(ThreadGetMethod.class); methods.addBinding().to(VacationResponseGetMethod.class); diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaGetMethodTest.java new file mode 100644 index 0000000000..eb6612b099 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedQuotaGetMethodTest.java @@ -0,0 +1,55 @@ +/**************************************************************** + * 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.jmap.rfc8621.distributed; + +import org.apache.james.CassandraExtension; +import org.apache.james.CassandraRabbitMQJamesConfiguration; +import org.apache.james.CassandraRabbitMQJamesServerMain; +import org.apache.james.DockerOpenSearchExtension; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.jmap.rfc8621.contract.QuotaGetMethodContract; +import org.apache.james.modules.AwsS3BlobStoreExtension; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class DistributedQuotaGetMethodTest implements QuotaGetMethodContract { + + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder<CassandraRabbitMQJamesConfiguration>(tmpDir -> + CassandraRabbitMQJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .blobStore(BlobStoreConfiguration.builder() + .s3() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(new DockerOpenSearchExtension()) + .extension(new CassandraExtension()) + .extension(new RabbitMQExtension()) + .extension(new AwsS3BlobStoreExtension()) + .server(configuration -> CassandraRabbitMQJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala new file mode 100644 index 0000000000..ed3f742667 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala @@ -0,0 +1,906 @@ +/**************************************************************** + * 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.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.core.quota.{QuotaCountLimit, QuotaSizeLimit} +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.mail.{CountResourceType, QuotaIdFactory} +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ANDRE, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.mailbox.MessageManager.AppendCommand +import org.apache.james.mailbox.model.MailboxPath +import org.apache.james.mime4j.dom.Message +import org.apache.james.modules.{MailboxProbeImpl, QuotaProbesImpl} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +import java.nio.charset.StandardCharsets + + +trait QuotaGetMethodContract { + + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addUser(BOB.asString, BOB_PASSWORD) + .addUser(ANDRE.asString(), ANDRE_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .build + } + + @Test + def listShouldEmptyWhenAccountDoesNotHaveQuotas(): Unit = { + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "${INSTANCE.value}", + | "list": [], + | "notFound": [] + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldReturnListWhenQuotasIsProvided(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(99L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "state": "${INSTANCE.value}", + | "list": [ + | { + | "used": 0, + | "name": "account:count:Mail", + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 100, + | "warnLimit": 90, + | "resourceType": "count", + | "scope": "account" + | }, + | { + | "used": 0, + | "name": "account:octets:Mail", + | "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 99, + | "warnLimit": 89, + | "resourceType": "octets", + | "scope": "account" + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + @Test + def quotaGetShouldReturnEmptyListWhenIdsAreEmpty(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": [] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "state": "${INSTANCE.value}", + | "list": [] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + @Test + def quotaGetShouldReturnNotFoundWhenIdDoesNotExist(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["notfound123"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [ "notfound123" ], + | "state": "${INSTANCE.value}", + | "list": [] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + @Test + def quotaGetShouldReturnNotFoundAndListWhenMixCases(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(900)) + + val quotaId = QuotaIdFactory.from(bobQuotaRoot, CountResourceType) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["notfound123", "${quotaId.value}"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [ "notfound123" ], + | "state": "${INSTANCE.value}", + | "list": [ + | { + | "used": 0, + | "name": "account:count:Mail", + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 100, + | "warnLimit": 90, + | "resourceType": "count", + | "scope": "account" + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + @Test + def quotaGetShouldReturnRightUsageQuota(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(900L)) + + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(MailboxPath.inbox(BOB)) + server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(BOB.asString(), MailboxPath.inbox(BOB), AppendCommand.from(Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId.serialize() + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [ ], + | "state": "${INSTANCE.value}", + | "list": [ + | { + | "used": 1, + | "name": "account:count:Mail", + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 100, + | "warnLimit": 90, + | "resourceType": "count", + | "scope": "account" + | }, + | { + | "used": 85, + | "name": "account:octets:Mail", + | "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 900, + | "warnLimit": 810, + | "resourceType": "octets", + | "scope": "account" + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + + @Test + def quotaGetShouldFailWhenWrongAccountId(): Unit = { + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "unknownAccountId", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin + + val response = `given` + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | ["error", { + | "type": "accountNotFound" + | }, "c1"] + | ] + |}""".stripMargin) + } + + @Test + def quotaGetShouldFailWhenOmittingOneCapability(): Unit = { + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin + + val response = `given` + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "error", + | { + | "type": "unknownMethod", + | "description":"Missing capability(ies): urn:ietf:params:jmap:quota" + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldFailWhenOmittingAllCapability(): Unit = { + val request = + s"""{ + | "using": [], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin + + val response = `given` + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "error", + | { + | "type": "unknownMethod", + | "description":"Missing capability(ies): urn:ietf:params:jmap:quota, urn:ietf:params:jmap:core" + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldNotReturnQuotaDataOfOtherAccount(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val andreQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(ANDRE)) + quotaProbe.setMaxMessageCount(andreQuotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "${INSTANCE.value}", + | "list": [], + | "notFound": [] + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldReturnNotFoundWhenDoesNotPermission(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val andreQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(ANDRE)) + quotaProbe.setMaxMessageCount(andreQuotaRoot, QuotaCountLimit.count(100L)) + + val quotaId = QuotaIdFactory.from(andreQuotaRoot, CountResourceType) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${quotaId.value}"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "${INSTANCE.value}", + | "list": [], + | "notFound": [ "${quotaId}" ] + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldReturnIdWhenNoPropertiesRequested(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val quotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(quotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null, + | "properties": [] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "${INSTANCE.value}", + | "list": [ + | { + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528" + | } + | ], + | "notFound": [] + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldReturnOnlyRequestedProperties(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val quotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(quotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null, + | "properties": ["name","used","limit"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "${INSTANCE.value}", + | "list": [ + | { + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "used": 0, + | "name": "account:count:Mail", + | "limit": 100 + | } + | ], + | "notFound": [] + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def quotaGetShouldFailWhenInvalidProperties(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val quotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(quotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null, + | "properties": ["invalid"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "error", + | { + | "type": "invalidArguments", + | "description": "The following properties [invalid] do not exist." + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def quotaGetShouldFailWhenInvalidIds(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val quotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(quotaRoot, QuotaCountLimit.count(100L)) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["#==id"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "error", + | { + | "type": "invalidArguments", + | "description": "$${json-unit.any-string}" + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def quotaGetShouldReturnOnlyUserQuota(server: GuiceJamesServer): Unit = { + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.size(101L)) + + quotaProbe.setGlobalMaxMessageCount(QuotaCountLimit.count(90L)) + quotaProbe.setGlobalMaxStorage(QuotaSizeLimit.size(99L)) + + quotaProbe.setDomainMaxMessage(DOMAIN, QuotaCountLimit.count(80L)) + quotaProbe.setDomainMaxStorage(DOMAIN, QuotaSizeLimit.size(88L)) + + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(MailboxPath.inbox(BOB)) + server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(BOB.asString(), MailboxPath.inbox(BOB), AppendCommand.from(Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId.serialize() + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "state": "${INSTANCE.value}", + | "list": [ + | { + | "used": 1, + | "name": "account:count:Mail", + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 100, + | "warnLimit": 90, + | "resourceType": "count", + | "scope": "account" + | }, + | { + | "used": 85, + | "name": "account:octets:Mail", + | "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947", + | "dataTypes": [ + | "Mail" + | ], + | "limit": 101, + | "warnLimit": 90, + | "resourceType": "octets", + | "scope": "account" + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaGetMethodTest.java new file mode 100644 index 0000000000..e2bfac204c --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryQuotaGetMethodTest.java @@ -0,0 +1,44 @@ +/**************************************************************** + * 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.jmap.rfc8621.memory; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.MemoryJamesConfiguration; +import org.apache.james.MemoryJamesServerMain; +import org.apache.james.jmap.rfc8621.contract.QuotaGetMethodContract; +import org.apache.james.modules.TestJMAPServerModule; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class MemoryQuotaGetMethodTest implements QuotaGetMethodContract { + + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder<MemoryJamesConfiguration>(tmpDir -> + MemoryJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(configuration -> MemoryJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala new file mode 100644 index 0000000000..2ca89be9ab --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/QuotaSerializer.scala @@ -0,0 +1,73 @@ +/**************************************************************** + * 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.jmap.json + +import eu.timepit.refined +import org.apache.james.jmap.core.Id.IdConstraint +import org.apache.james.jmap.core.{Properties, UuidState} +import org.apache.james.jmap.mail.{DataType, JmapQuota, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, ResourceType, Scope, UnparsedQuotaId} +import play.api.libs.json._ + +object QuotaSerializer { + + private implicit val unparsedQuotaIdWrites: Writes[UnparsedQuotaId] = Json.valueWrites[UnparsedQuotaId] + private implicit val unparsedQuotaIdReads: Reads[UnparsedQuotaId] = { + case JsString(string) => refined.refineV[IdConstraint](string) + .fold( + e => JsError(s"vacation response id does not match Id constraints: $e"), + id => JsSuccess(UnparsedQuotaId(id))) + case _ => JsError("vacation response id needs to be represented by a JsString") + } + private implicit val quotaIdsReads: Reads[QuotaIds] = Json.valueReads[QuotaIds] + + private implicit val quotaGetRequestReads: Reads[QuotaGetRequest] = Json.reads[QuotaGetRequest] + + private implicit val stateWrites: Writes[UuidState] = Json.valueWrites[UuidState] + + private implicit val resourceTypeWrite: Writes[ResourceType] = resourceType => JsString(resourceType.asString()) + private implicit val scopeWrites: Writes[Scope] = scope => JsString(scope.asString()) + private implicit val dataTypeWrites: Writes[DataType] = dataType => JsString(dataType.asString()) + private implicit val quotaNameWrites: Writes[QuotaName] = Json.valueWrites[QuotaName] + private implicit val quotaDescriptionWrites: Writes[QuotaDescription] = Json.valueWrites[QuotaDescription] + + private implicit val jmapQuotaWrites: Writes[JmapQuota] = Json.writes[JmapQuota] + + private implicit val quotaNotFoundWrites: Writes[QuotaNotFound] = + notFound => JsArray(notFound.value.toList.map(id => JsString(id.id.value))) + private implicit val quotaGetResponseWrites: Writes[QuotaGetResponse] = Json.writes[QuotaGetResponse] + + def deserializeQuotaGetRequest(input: String): JsResult[QuotaGetRequest] = Json.parse(input).validate[QuotaGetRequest] + + def deserializeQuotaGetRequest(input: JsValue): JsResult[QuotaGetRequest] = Json.fromJson[QuotaGetRequest](input) + + def serialize(quotaGetResponse: QuotaGetResponse, properties: Properties): JsValue = + Json.toJson(quotaGetResponse) + .transform((__ \ "list").json.update { + case JsArray(underlying) => JsSuccess(JsArray(underlying.map { + case jsonObject: JsObject => + JmapQuota.propertiesFiltered(properties) + .filter(jsonObject) + case jsValue => jsValue + })) + }).get + + def serialize(response: QuotaGetResponse): JsValue = Json.toJson(response) + +} diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala index 7fcb91c3ff..938a6612e1 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Quotas.scala @@ -19,13 +19,21 @@ package org.apache.james.jmap.mail +import com.google.common.hash.Hashing +import eu.timepit.refined.auto._ import org.apache.james.core.Domain +import org.apache.james.core.quota.{QuotaCountLimit, QuotaCountUsage, QuotaSizeLimit, QuotaSizeUsage} +import org.apache.james.jmap.core.Id.Id import org.apache.james.jmap.core.UnsignedInt.UnsignedInt -import org.apache.james.mailbox.model.{QuotaRoot => ModelQuotaRoot} +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.core.{AccountId, Id, Properties, UnsignedInt, UuidState} +import org.apache.james.jmap.method.WithAccountId +import org.apache.james.mailbox.model.{Quota => ModelQuota, QuotaRoot => ModelQuotaRoot} +import java.nio.charset.StandardCharsets import scala.compat.java8.OptionConverters._ -object QuotaRoot{ +object QuotaRoot { def toJmap(quotaRoot: ModelQuotaRoot) = QuotaRoot(quotaRoot.getValue, quotaRoot.getDomain.asScala) } @@ -35,7 +43,9 @@ case class QuotaRoot(value: String, domain: Option[Domain]) { object Quotas { sealed trait Type + case object Storage extends Type + case object Message extends Type def from(quotas: Map[QuotaId, Quota]) = new Quotas(quotas) @@ -59,4 +69,124 @@ case class Quota(quota: Map[Quotas.Type, Value]) extends AnyVal case class Value(used: UnsignedInt, max: Option[UnsignedInt]) -case class Quotas(quotas: Map[QuotaId, Quota]) extends AnyVal \ No newline at end of file +case class Quotas(quotas: Map[QuotaId, Quota]) extends AnyVal + +case class UnparsedQuotaId(id: Id) + +case class QuotaIds(value: List[UnparsedQuotaId]) + +case class QuotaGetRequest(accountId: AccountId, + ids: Option[QuotaIds], + properties: Option[Properties]) extends WithAccountId + +object JmapQuota { + val WARN_LIMIT_PERCENTAGE = 0.9 + val allProperties: Properties = Properties("id", "resourceType", "used", "limit", "scope", "name", "dataTypes", "warnLimit", "softLimit", "description") + val idProperty: Properties = Properties("id") + + def propertiesFiltered(requestedProperties: Properties) : Properties = idProperty ++ requestedProperties + + def extractUserMessageCountQuota(quota: ModelQuota[QuotaCountLimit, QuotaCountUsage], countQuotaIdPlaceHolder: Id): Option[JmapQuota] = + Option(quota.getLimitByScope.get(ModelQuota.Scope.User)) + .map(limit => JmapQuota( + id = countQuotaIdPlaceHolder, + resourceType = CountResourceType, + used = UnsignedInt.liftOrThrow(quota.getUsed.asLong()), + limit = UnsignedInt.liftOrThrow(limit.asLong()), + scope = AccountScope, + name = QuotaName.from(AccountScope, CountResourceType, List(MailDataType)), + dataTypes = List(MailDataType), + warnLimit = Some(UnsignedInt.liftOrThrow((limit.asLong() * WARN_LIMIT_PERCENTAGE).toLong)))) + + def extractUserMessageSizeQuota(quota: ModelQuota[QuotaSizeLimit, QuotaSizeUsage], sizeQuotaIdPlaceHolder: Id): Option[JmapQuota] = + Option(quota.getLimitByScope.get(ModelQuota.Scope.User)) + .map(limit => JmapQuota( + id = sizeQuotaIdPlaceHolder, + resourceType = OctetsResourceType, + used = UnsignedInt.liftOrThrow(quota.getUsed.asLong()), + limit = UnsignedInt.liftOrThrow(limit.asLong()), + scope = AccountScope, + name = QuotaName.from(AccountScope, OctetsResourceType, List(MailDataType)), + dataTypes = List(MailDataType), + warnLimit = Some(UnsignedInt.liftOrThrow((limit.asLong() * WARN_LIMIT_PERCENTAGE).toLong)))) +} + +case class JmapQuota(id: Id, + resourceType: ResourceType, + used: UnsignedInt, + limit: UnsignedInt, + scope: Scope, + name: QuotaName, + dataTypes: List[DataType], + warnLimit: Option[UnsignedInt] = None, + softLimit: Option[UnsignedInt] = None, + description: Option[QuotaDescription] = None) + +object QuotaName { + def from(scope: Scope, resourceType: ResourceType, dataTypes: List[DataType]): QuotaName = + QuotaName(s"${scope.asString()}:${resourceType.asString()}:${dataTypes.map(_.asString()).mkString("_")}") +} + +case class QuotaName(string: String) + +case class QuotaDescription(string: String) + +sealed trait ResourceType { + def asString(): String +} + +case object CountResourceType extends ResourceType { + override def asString(): String = "count" +} + +case object OctetsResourceType extends ResourceType { + override def asString(): String = "octets" +} + +trait Scope { + def asString(): String +} + +case object AccountScope extends Scope { + override def asString(): String = "account" +} + +trait DataType { + def asString(): String +} + +case object MailDataType extends DataType { + override def asString(): String = "Mail" +} + +case class QuotaGetResponse(accountId: AccountId, + state: UuidState, + list: List[JmapQuota], + notFound: QuotaNotFound) + +case class QuotaNotFound(value: Set[UnparsedQuotaId]) { + def merge(other: QuotaNotFound): QuotaNotFound = QuotaNotFound(this.value ++ other.value) +} + +object QuotaIdFactory { + def from(quotaRoot: ModelQuotaRoot, resourceType: ResourceType): Id = + Id.validate(Hashing.sha256.hashBytes((quotaRoot.asString() + resourceType.asString()).getBytes(StandardCharsets.UTF_8)).toString).toOption.get +} + +object QuotaResponseGetResult { + def empty: QuotaResponseGetResult = QuotaResponseGetResult() + + def merge(result1: QuotaResponseGetResult, result2: QuotaResponseGetResult): QuotaResponseGetResult = result1.merge(result2) +} + +case class QuotaResponseGetResult(jmapQuotaSet: Set[JmapQuota] = Set(), notFound: QuotaNotFound = QuotaNotFound(Set())) { + def merge(other: QuotaResponseGetResult): QuotaResponseGetResult = + QuotaResponseGetResult(this.jmapQuotaSet ++ other.jmapQuotaSet, + this.notFound.merge(other.notFound)) + + def asResponse(accountId: AccountId): QuotaGetResponse = + QuotaGetResponse(accountId = accountId, + state = INSTANCE, + list = jmapQuotaSet.toList, + notFound = notFound) +} \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala new file mode 100644 index 0000000000..73dc94ade1 --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/QuotaGetMethod.scala @@ -0,0 +1,131 @@ +/**************************************************************** + * 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.jmap.method + +import eu.timepit.refined.auto._ +import org.apache.james.core.Username +import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE, JMAP_QUOTA} +import org.apache.james.jmap.core.Invocation.{Arguments, MethodCallId, MethodName} +import org.apache.james.jmap.core.{ErrorCode, Invocation, MissingCapabilityException, Properties} +import org.apache.james.jmap.json.{QuotaSerializer, ResponseSerializer} +import org.apache.james.jmap.mail.{CountResourceType, JmapQuota, OctetsResourceType, QuotaGetRequest, QuotaIdFactory, QuotaNotFound, QuotaResponseGetResult} +import org.apache.james.jmap.routes.SessionSupplier +import org.apache.james.lifecycle.api.Startable +import org.apache.james.mailbox.MailboxSession +import org.apache.james.mailbox.model.QuotaRoot +import org.apache.james.mailbox.quota.{QuotaManager, UserQuotaRootResolver} +import org.apache.james.metrics.api.MetricFactory +import org.reactivestreams.Publisher +import play.api.libs.json.{JsError, JsObject, JsSuccess} +import reactor.core.scala.publisher.{SFlux, SMono} +import org.apache.james.jmap.core.Id.Id +import org.apache.james.util.ReactorUtils + +import javax.inject.Inject + +class QuotaGetMethod @Inject()(val metricFactory: MetricFactory, + val sessionSupplier: SessionSupplier, + val quotaManager: QuotaManager, + val quotaRootResolver: UserQuotaRootResolver) extends MethodRequiringAccountId[QuotaGetRequest] with Startable { + + override val methodName: Invocation.MethodName = MethodName("Quota/get") + override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_QUOTA, JMAP_CORE) + val jmapQuotaManagerWrapper: JmapQuotaManagerWrapper = JmapQuotaManagerWrapper(quotaManager, quotaRootResolver) + + override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: QuotaGetRequest): Publisher[InvocationWithContext] = { + val requestedProperties: Properties = request.properties.getOrElse(JmapQuota.allProperties) + + (requestedProperties -- JmapQuota.allProperties match { + case invalidProperties if invalidProperties.isEmpty() => getQuotaGetResponse(request, mailboxSession.getUser) + .reduce(QuotaResponseGetResult.empty)(QuotaResponseGetResult.merge) + .map(result => result.asResponse(accountId = request.accountId)) + .map(response => Invocation( + methodName = methodName, + arguments = Arguments(QuotaSerializer.serialize(response, requestedProperties).as[JsObject]), + methodCallId = invocation.invocation.methodCallId)) + case invalidProperties: Properties => SMono.just(Invocation.error( + errorCode = ErrorCode.InvalidArguments, + description = s"The following properties [${invalidProperties.format()}] do not exist.", + methodCallId = invocation.invocation.methodCallId)) + }) + .map(InvocationWithContext(_, invocation.processingContext)) + .onErrorResume { case e: Exception => handleRequestValidationErrors(e, invocation.invocation.methodCallId) + .map(errorInvocation => InvocationWithContext(errorInvocation, invocation.processingContext)) + } + } + + override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, QuotaGetRequest] = + QuotaSerializer.deserializeQuotaGetRequest(invocation.arguments.value) match { + case JsSuccess(quotaGetRequest, _) => Right(quotaGetRequest) + case errors: JsError => Left(new IllegalArgumentException(ResponseSerializer.serialize(errors).toString)) + } + + private def getQuotaGetResponse(quotaGetRequest: QuotaGetRequest, username: Username): SFlux[QuotaResponseGetResult] = + quotaGetRequest.ids match { + case None => + jmapQuotaManagerWrapper.list(username) + .collectSeq() + .map(listJmapQuota => QuotaResponseGetResult(jmapQuotaSet = listJmapQuota.toSet)) + .flatMapMany(result => SFlux.just(result)) + case Some(ids) => SFlux.fromIterable(ids.value) + .flatMap(id => jmapQuotaManagerWrapper.get(username, id.id) + .map(jmapQuota => QuotaResponseGetResult(jmapQuotaSet = Set(jmapQuota))) + .switchIfEmpty(SMono.just(QuotaResponseGetResult(notFound = QuotaNotFound(Set(id)))))) + } + + private def handleRequestValidationErrors(exception: Exception, methodCallId: MethodCallId): SMono[Invocation] = exception match { + case _: MissingCapabilityException => SMono.just(Invocation.error(ErrorCode.UnknownMethod, methodCallId)) + case e: IllegalArgumentException => SMono.just(Invocation.error(ErrorCode.InvalidArguments, e.getMessage, methodCallId)) + } +} + +case class JmapQuotaManagerWrapper(private var quotaManager: QuotaManager, + private var quotaRootResolver: UserQuotaRootResolver) { + def get(username: Username, quotaId: Id): SFlux[JmapQuota] = + SMono.just(quotaRootResolver.forUser(username)) + .flatMapMany(quotaRoot => getJmapQuota(quotaRoot, Some(quotaId))) + + def list(username: Username): SFlux[JmapQuota] = + SMono.just(quotaRootResolver.forUser(username)) + .flatMapMany(quotaRoot => getJmapQuota(quotaRoot)) + + private def getJmapQuota(quotaRoot: QuotaRoot, quotaId: Option[Id] = None): SFlux[JmapQuota] = + (quotaId match { + case None => SMono(quotaManager.getQuotasReactive(quotaRoot)) + .flatMapMany(quotas => SMono.fromCallable(() => JmapQuota.extractUserMessageCountQuota(quotas.getMessageQuota, QuotaIdFactory.from(quotaRoot, CountResourceType))) + .mergeWith(SMono.fromCallable(() => JmapQuota.extractUserMessageSizeQuota(quotas.getStorageQuota, QuotaIdFactory.from(quotaRoot, OctetsResourceType))))) + + case Some(quotaIdValue) => + val quotaCountPublisher = SMono.fromCallable(() => QuotaIdFactory.from(quotaRoot, CountResourceType)) + .filter(countQuotaId => countQuotaId.value.equals(quotaIdValue.value)) + .flatMap(_ => SMono.fromCallable(() => quotaManager.getMessageQuota(quotaRoot)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)) + .map(quota => JmapQuota.extractUserMessageCountQuota(quota, quotaIdValue)) + + val quotaSizePublisher = SMono.fromCallable(() => QuotaIdFactory.from(quotaRoot, OctetsResourceType)) + .filter(sizeQuotaId => sizeQuotaId.value.equals(quotaIdValue.value)) + .flatMap(_ => SMono.fromCallable(() => quotaManager.getStorageQuota(quotaRoot)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)) + .map(quota => JmapQuota.extractUserMessageSizeQuota(quota, quotaIdValue)) + + quotaCountPublisher.mergeWith(quotaSizePublisher) + }).flatMap(SMono.justOrEmpty) + +} \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala new file mode 100644 index 0000000000..b1e790af23 --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/QuotaSerializerTest.scala @@ -0,0 +1,222 @@ +/**************************************************************** + * 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.jmap.json + +import eu.timepit.refined.auto._ +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.james.jmap.core.{AccountId, Id, Properties, UnsignedInt, UuidState} +import org.apache.james.jmap.json.Fixture.id +import org.apache.james.jmap.json.QuotaSerializerTest.ACCOUNT_ID +import org.apache.james.jmap.mail.{AccountScope, CountResourceType, JmapQuota, MailDataType, OctetsResourceType, QuotaDescription, QuotaGetRequest, QuotaGetResponse, QuotaIds, QuotaName, QuotaNotFound, UnparsedQuotaId} +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import play.api.libs.json.{JsSuccess, Json} + +object QuotaSerializerTest { + private val ACCOUNT_ID: AccountId = AccountId(id) +} + +class QuotaSerializerTest extends AnyWordSpec with Matchers { + + "Deserialize QuotaGetRequest" should { + "succeed when properties are missing" in { + val expectedRequestObject = QuotaGetRequest( + accountId = ACCOUNT_ID, + ids = Some(QuotaIds(List(UnparsedQuotaId("singleton")))), + properties = None) + + QuotaSerializer.deserializeQuotaGetRequest( + """ + |{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "ids": ["singleton"] + |} + |""".stripMargin) should equal(JsSuccess(expectedRequestObject)) + } + + "succeed when properties are null" in { + val expectedRequestObject = QuotaGetRequest( + accountId = ACCOUNT_ID, + ids = Some(QuotaIds(List(UnparsedQuotaId("singleton")))), + properties = None) + + QuotaSerializer.deserializeQuotaGetRequest( + """ + |{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "ids": ["singleton"], + | "properties": null + |} + |""".stripMargin) should equal(JsSuccess(expectedRequestObject)) + } + + "succeed when properties are empty" in { + val expectedRequestObject = QuotaGetRequest( + accountId = ACCOUNT_ID, + ids = Some(QuotaIds(List(UnparsedQuotaId("singleton")))), + properties = Some(Properties.empty())) + + QuotaSerializer.deserializeQuotaGetRequest( + """ + |{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "ids": ["singleton"], + | "properties": [] + |} + |""".stripMargin) should equal(JsSuccess(expectedRequestObject)) + } + + "succeed when ids is null" in { + val expectedRequestObject = QuotaGetRequest( + accountId = ACCOUNT_ID, + ids = None, + properties = None) + + QuotaSerializer.deserializeQuotaGetRequest( + """ + |{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "ids": null + |} + |""".stripMargin) should equal(JsSuccess(expectedRequestObject)) + } + + "succeed when multiple ids" in { + val expectedRequestObject = QuotaGetRequest( + accountId = ACCOUNT_ID, + ids = Some(QuotaIds(List(UnparsedQuotaId("singleton"), UnparsedQuotaId("randomId")))), + properties = Some(Properties.empty())) + + QuotaSerializer.deserializeQuotaGetRequest( + """ + |{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "ids": ["singleton", "randomId"], + | "properties": [] + |} + |""".stripMargin) should equal(JsSuccess(expectedRequestObject)) + } + } + + "Serialize QuotaGetResponse" should { + "succeed" in { + val jmapQuota: JmapQuota = JmapQuota( + id = Id.validate("aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8").toOption.get, + resourceType = CountResourceType, + used = UnsignedInt.liftOrThrow(1), + limit = UnsignedInt.liftOrThrow(2), + scope = AccountScope, + name = QuotaName("name1"), + dataTypes = List(MailDataType), + warnLimit = Some(UnsignedInt.liftOrThrow(123)), + softLimit = Some(UnsignedInt.liftOrThrow(456)), + description = Some(QuotaDescription("Description 1"))) + + val actualValue: QuotaGetResponse = QuotaGetResponse( + accountId = ACCOUNT_ID, + state = UuidState.INSTANCE, + list = List(jmapQuota), + notFound = QuotaNotFound(Set(UnparsedQuotaId("notfound2")))) + + val expectedJson: String = + """{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "state": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "list": [ + | { + | "id": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "resourceType": "count", + | "used": 1, + | "limit": 2, + | "scope": "account", + | "name": "name1", + | "dataTypes": [ + | "Mail" + | ], + | "warnLimit": 123, + | "softLimit": 456, + | "description": "Description 1" + | } + | ], + | "notFound": [ + | "notfound2" + | ] + |}""".stripMargin + + assertThatJson(Json.stringify(QuotaSerializer.serialize(actualValue))).isEqualTo(expectedJson) + } + + "succeed when list has multiple quota" in { + val jmapQuota: JmapQuota = JmapQuota( + id = Id.validate("aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8").toOption.get, + resourceType = CountResourceType, + used = UnsignedInt.liftOrThrow(1), + limit = UnsignedInt.liftOrThrow(2), + scope = AccountScope, + name = QuotaName("name1"), + dataTypes = List(MailDataType), + description = None) + + val jmapQuota2 = jmapQuota.copy(id = Id.validate("aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy9").toOption.get, + resourceType = OctetsResourceType, + scope = AccountScope, + name = QuotaName("name2")) + + val actualValue: QuotaGetResponse = QuotaGetResponse( + accountId = ACCOUNT_ID, + state = UuidState.INSTANCE, + list = List(jmapQuota, jmapQuota2), + notFound = QuotaNotFound(Set())) + + val expectedJson: String = + """{ + | "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "state": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "list": [ + | { + | "id": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8", + | "resourceType": "count", + | "used": 1, + | "limit": 2, + | "scope": "account", + | "name": "name1", + | "dataTypes": [ + | "Mail" + | ] + | }, + | { + | "id": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy9", + | "resourceType": "octets", + | "used": 1, + | "limit": 2, + | "scope": "account", + | "name": "name2", + | "dataTypes": [ + | "Mail" + | ] + | } + | ], + | "notFound": [] + |}""".stripMargin + + assertThatJson(Json.stringify(QuotaSerializer.serialize(actualValue))).isEqualTo(expectedJson) + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org