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 533b94b140 JAMES-3830 JMAP Quota draft compatibility (#1644) 533b94b140 is described below commit 533b94b1404475391996a260119bb5009b9923c6 Author: Trần Hồng Quân <55171818+quantranhong1...@users.noreply.github.com> AuthorDate: Tue Jul 18 08:18:20 2023 +0700 JAMES-3830 JMAP Quota draft compatibility (#1644) --- .../docs/modules/ROOT/pages/configure/jvm.adoc | 13 +++ .../rfc8621/contract/QuotaGetMethodContract.scala | 128 +++++++++++++++++++++ .../scala/org/apache/james/jmap/mail/Quotas.scala | 20 +++- .../apache/james/jmap/method/QuotaGetMethod.scala | 7 +- .../james/jmap/json/QuotaSerializerTest.scala | 49 ++++++++ src/site/xdoc/server/config-system.xml | 4 + 6 files changed, 216 insertions(+), 5 deletions(-) diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jvm.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jvm.adoc index d95acb2414..f1b9055822 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jvm.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/jvm.adoc @@ -65,3 +65,16 @@ james.blob.id.hash.encoding=base16 Optional. String. Defaults to base64Url. +== JMAP Quota draft compatibility + +Some JMAP clients depend on the JMAP Quota draft specifications. The property `james.jmap.quota.draft.compatibility` allows +to enable JMAP Quota draft compatibility for those clients and allow them a time window to adapt to the RFC-9245 JMAP Quota. + +Optional. Boolean. Default to false. + +Ex in `jvm.properties` +---- +james.jmap.quota.draft.compatibility=true +---- +To enable the compatibility. + 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 index a095d89ac2..d4f80c22a5 100644 --- 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 @@ -64,6 +64,8 @@ trait QuotaGetMethodContract { .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .build + + System.clearProperty("james.jmap.quota.draft.compatibility") } @Test @@ -1359,4 +1361,130 @@ trait QuotaGetMethodContract { |""".stripMargin) } + @Test + def shouldSupportQuotaGetDraftCompatibilityWhenEnabled(server: GuiceJamesServer): Unit = { + System.setProperty("james.jmap.quota.draft.compatibility", "true") + + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.unlimited()) + + 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) + .withOptions(new Options(IGNORING_ARRAY_ORDER)) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697", + | "list": [ + | { + | "used": 0, + | "name": "#private&b...@domain.tld@domain.tld:account:count:Mail", + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "types": ["Mail"], + | "dataTypes": ["Mail"], + | "hardLimit": 100, + | "limit": 100, + | "warnLimit": 90, + | "resourceType": "count", + | "scope": "account" + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + @Test + def quotaGetDraftCompatibilityShouldStillSupportPropertiesFiltering(server: GuiceJamesServer): Unit = { + System.setProperty("james.jmap.quota.draft.compatibility", "true") + + val quotaProbe = server.getProbe(classOf[QuotaProbesImpl]) + val bobQuotaRoot = quotaProbe.getQuotaRoot(MailboxPath.inbox(BOB)) + quotaProbe.setMaxMessageCount(bobQuotaRoot, QuotaCountLimit.count(100L)) + quotaProbe.setMaxStorage(bobQuotaRoot, QuotaSizeLimit.unlimited()) + + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null, + | "properties": ["limit", "dataTypes"] + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .withOptions(new Options(IGNORING_ARRAY_ORDER)) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [], + | "state": "84c40a2e-76a1-3f84-a1e8-862104c7a697", + | "list": [ + | { + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "dataTypes": ["Mail"], + | "limit": 100 + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + } 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 d2aaadeec2..63d60cb641 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 @@ -83,12 +83,20 @@ case class QuotaGetRequest(accountId: AccountId, properties: Option[Properties]) extends WithAccountId object JmapQuota { - val WARN_LIMIT_PERCENTAGE = 0.9 - val allProperties: Properties = Properties("id", "resourceType", "used", "hardLimit", "scope", "name", "types", "warnLimit", "softLimit", "description") - val idProperty: Properties = Properties("id") + private val WARN_LIMIT_PERCENTAGE = 0.9 + private val allRfc9245Properties: Properties = Properties("id", "resourceType", "used", "hardLimit", "scope", "name", "types", "warnLimit", "softLimit", "description") + private val allRfc9245PropertiesWithDraftProperties: Properties = allRfc9245Properties ++ Properties("dataTypes", "limit") + private val idProperty: Properties = Properties("id") def propertiesFiltered(requestedProperties: Properties): Properties = idProperty ++ requestedProperties + def allProperties(draftBackwardCompatibility: Boolean): Properties = + if (draftBackwardCompatibility) { + allRfc9245PropertiesWithDraftProperties + } else { + allRfc9245Properties + } + def extractUserMessageCountQuota(quota: ModelQuota[QuotaCountLimit, QuotaCountUsage], countQuotaIdPlaceHolder: Id, quotaRoot: ModelQuotaRoot): Option[JmapQuota] = Option(quota.getLimit) .filter(_.isLimited) @@ -97,9 +105,11 @@ object JmapQuota { resourceType = CountResourceType, used = UnsignedInt.liftOrThrow(quota.getUsed.asLong()), hardLimit = UnsignedInt.liftOrThrow(limit.asLong()), + limit = Some(UnsignedInt.liftOrThrow(limit.asLong())), scope = AccountScope, name = QuotaName.from(quotaRoot, AccountScope, CountResourceType, List(MailDataType)), types = List(MailDataType), + dataTypes = Some(List(MailDataType)), warnLimit = Some(UnsignedInt.liftOrThrow((limit.asLong() * WARN_LIMIT_PERCENTAGE).toLong)))) def extractUserMessageSizeQuota(quota: ModelQuota[QuotaSizeLimit, QuotaSizeUsage], sizeQuotaIdPlaceHolder: Id, quotaRoot: ModelQuotaRoot): Option[JmapQuota] = @@ -110,9 +120,11 @@ object JmapQuota { resourceType = OctetsResourceType, used = UnsignedInt.liftOrThrow(quota.getUsed.asLong()), hardLimit = UnsignedInt.liftOrThrow(limit.asLong()), + limit = Some(UnsignedInt.liftOrThrow(limit.asLong())), scope = AccountScope, name = QuotaName.from(quotaRoot, AccountScope, OctetsResourceType, List(MailDataType)), types = List(MailDataType), + dataTypes = Some(List(MailDataType)), warnLimit = Some(UnsignedInt.liftOrThrow((limit.asLong() * WARN_LIMIT_PERCENTAGE).toLong)))) def correspondingState(quotas: Seq[JmapQuota]): UuidState = @@ -124,9 +136,11 @@ case class JmapQuota(id: Id, resourceType: ResourceType, used: UnsignedInt, hardLimit: UnsignedInt, + limit: Option[UnsignedInt] = None, scope: Scope, name: QuotaName, types: List[DataType], + dataTypes: Option[List[DataType]] = None, warnLimit: Option[UnsignedInt] = None, softLimit: Option[UnsignedInt] = None, description: Option[QuotaDescription] = None) 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 index 9e8ea70acc..09d8848a6d 100644 --- 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 @@ -48,10 +48,13 @@ class QuotaGetMethod @Inject()(val metricFactory: MetricFactory, override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_QUOTA, JMAP_CORE) val jmapQuotaManagerWrapper: JmapQuotaManagerWrapper = JmapQuotaManagerWrapper(quotaManager, quotaRootResolver) + private lazy val JMAP_QUOTA_DRAFT_COMPATIBILITY: Boolean = Option(System.getProperty("james.jmap.quota.draft.compatibility")) + .exists(_.toBoolean) + override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: QuotaGetRequest): Publisher[InvocationWithContext] = { - val requestedProperties: Properties = request.properties.getOrElse(JmapQuota.allProperties) + val requestedProperties: Properties = request.properties.getOrElse(JmapQuota.allProperties(JMAP_QUOTA_DRAFT_COMPATIBILITY)) - (requestedProperties -- JmapQuota.allProperties match { + (requestedProperties -- JmapQuota.allProperties(JMAP_QUOTA_DRAFT_COMPATIBILITY) match { case invalidProperties if invalidProperties.isEmpty() => getQuotaGetResponse(request, mailboxSession.getUser, capabilities) .map(result => result.asResponse(accountId = request.accountId)) .map(response => Invocation( 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 index f78deb29c6..7fa4d6d852 100644 --- 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 @@ -163,6 +163,55 @@ class QuotaSerializerTest extends AnyWordSpec with Matchers { assertThatJson(Json.stringify(QuotaSerializer.serialize(actualValue))).isEqualTo(expectedJson) } + "succeed when draft compatibility" in { + val jmapQuota: JmapQuota = JmapQuota( + id = Id.validate("aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8").toOption.get, + resourceType = CountResourceType, + used = UnsignedInt.liftOrThrow(1), + hardLimit = UnsignedInt.liftOrThrow(2), + limit = Some(UnsignedInt.liftOrThrow(2)), + scope = AccountScope, + name = QuotaName("name1"), + types = List(MailDataType), + dataTypes = Some(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, + | "hardLimit": 2, + | "limit": 2, + | "scope": "account", + | "name": "name1", + | "types": [ "Mail" ], + | "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, diff --git a/src/site/xdoc/server/config-system.xml b/src/site/xdoc/server/config-system.xml index 86b499b03a..89a45c50c4 100644 --- a/src/site/xdoc/server/config-system.xml +++ b/src/site/xdoc/server/config-system.xml @@ -237,6 +237,10 @@ <dt><strong>james.blob.id.hash.encoding</strong></dt> <dd>Optional. String. Defaults to base64Url. <br/> The encoding type when encode blobId. The support value are: base16, hex, base32, base32Hex, base64, base64Url.</dd> + + <dt><strong>james.jmap.quota.draft.compatibility</strong></dt> + <dd>Optional. Boolean. Default to false. <br/> + This property allows to enable JMAP Quota draft compatibility for some JMAP clients and allow them a time window to adapt to the RFC-9245 JMAP Quota.</dd> </dl> </subsection> --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org