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 0b14c1527de8d4420dd08106e693449a407ffc1c Author: Rémi KOWALSKI <[email protected]> AuthorDate: Wed Jul 29 11:34:17 2020 +0200 JAMES-3357 Handle Mailbox/set create property --- .../james/jmap/rfc8621/RFC8621MethodsModule.java | 2 + .../DistributedMailboxSetMethodTest.java | 54 +++ .../contract/MailboxSetMethodContract.scala | 396 +++++++++++++++++++++ .../rfc8621/memory/MemoryMailboxSetMethodTest.java | 38 ++ .../org/apache/james/jmap/json/Serializer.scala | 87 ++++- .../org/apache/james/jmap/mail/MailboxSet.scala | 73 ++++ .../scala/org/apache/james/jmap/mail/package.scala | 28 ++ .../james/jmap/method/MailboxSetMethod.scala | 123 +++++++ .../org/apache/james/jmap/model/Invocation.scala | 5 +- 9 files changed, 800 insertions(+), 6 deletions(-) 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 3012858..fd68a61 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 @@ -36,6 +36,7 @@ import org.apache.james.jmap.json.Serializer; import org.apache.james.jmap.jwt.JWTAuthenticationStrategy; import org.apache.james.jmap.method.CoreEchoMethod; import org.apache.james.jmap.method.MailboxGetMethod; +import org.apache.james.jmap.method.MailboxSetMethod; import org.apache.james.jmap.method.Method; import org.apache.james.jmap.model.JmapRfc8621Configuration; import org.apache.james.jmap.routes.JMAPApiRoutes; @@ -62,6 +63,7 @@ public class RFC8621MethodsModule extends AbstractModule { Multibinder<Method> methods = Multibinder.newSetBinder(binder(), Method.class); methods.addBinding().to(CoreEchoMethod.class); methods.addBinding().to(MailboxGetMethod.class); + methods.addBinding().to(MailboxSetMethod.class); } @ProvidesIntoSet 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/DistributedMailboxSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedMailboxSetMethodTest.java new file mode 100644 index 0000000..60e7f40 --- /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/DistributedMailboxSetMethodTest.java @@ -0,0 +1,54 @@ +/**************************************************************** + * 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.DockerElasticSearchExtension; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.jmap.rfc8621.contract.MailboxSetMethodContract; +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 DistributedMailboxSetMethodTest implements MailboxSetMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder<CassandraRabbitMQJamesConfiguration>(tmpDir -> + CassandraRabbitMQJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .blobStore(BlobStoreConfiguration.builder() + .objectStorage() + .disableCache() + .deduplication()) + .build()) + .extension(new DockerElasticSearchExtension()) + .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/MailboxSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala new file mode 100644 index 0000000..76a21ec --- /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/MailboxSetMethodContract.scala @@ -0,0 +1,396 @@ +/**************************************************************** + * 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._ +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.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.modules.MailboxProbeImpl +import org.apache.james.utils.DataProbeImpl +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.{BeforeEach, Disabled, Test} + +trait MailboxSetMethodContract { + + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def mailboxSetShouldReturnNotCreatedWhenNameIsMissing(): Unit = { + val request= + """ + |{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + | "methodCalls": [ + | [ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "C42": { + | } + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin + + val response: String = + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [[ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notCreated": { + | "C42": { + | "type": "invalidArguments", + | "description": "Missing '/name' property in mailbox object" + | } + | } + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + @Disabled("should we support that? Anyway seems hard with Play-JSON") + def mailboxSetShouldReturnNotCreatedWhenUnknownParameter(): Unit = { + val request= + """ + |{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + | "methodCalls": [ + | [ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "C42": { + | "name": "plop", + | "unknown": "what?" + | } + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin + + val response: String = + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [[ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notCreated": { + | "C42": { + | "type": "invalidArguments", + | "description": "Unknown 'unknown' property in mailbox object" + | } + | } + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def mailboxSetShouldReturnNotCreatedWhenBadParameter(): Unit = { + val request= + """ + |{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + | "methodCalls": [ + | [ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "C42": { + | "name": "plop", + | "parentId": "" + | } + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin + + val response: String = + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [[ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notCreated": { + | "C42": { + | "type": "invalidArguments", + | "description": "'/parentId' property in mailbox object is not valid" + | } + | } + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def mailboxSetShouldCreateMailboxWhenOnlyName(server: GuiceJamesServer): Unit = { + val request= + """ + |{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + | "methodCalls": [ + | [ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "C42": { + | "name": "myMailbox" + | } + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin + + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + Assertions.assertThatCode(() => server.getProbe(classOf[MailboxProbeImpl]) + .getMailboxId("#private", BOB.asString(), "myMailbox")).doesNotThrowAnyException() + } + + @Test + def mailboxSetShouldReturnCreatedWhenOnlyName(server: GuiceJamesServer): Unit = { + val request= + """ + |{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + | "methodCalls": [ + | [ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "C42": { + | "name": "myMailbox" + | } + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin + + val response: String = + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl]) + .getMailboxId("#private", BOB.asString(), "myMailbox") + .serialize() + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [[ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "created": { + | "C42": { + | "id": "$mailboxId", + | "isSubscribed":true, + | "myRights":{"mayAddItems":true,"mayCreateChild":true,"mayDelete":true,"mayReadItems":true,"mayRemoveItems":true,"mayRename":true,"maySetKeywords":true,"maySetSeen":true,"maySubmit":true}, + | "totalEmails":0, + | "totalThreads":0, + | "unreadEmails":0, + | "unreadThreads":0 + | } + | } + | }, + | "c1"]] + |}""".stripMargin) + } + + @Test + def mailboxSetShouldReturnCreatedAndNotCreatedWhenOneWithOnlyNameAndOneWithoutName(server: GuiceJamesServer): Unit = { + val request= + """ + |{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], + | "methodCalls": [ + | [ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "C42": { + | "name": "myMailbox" + | }, + | "C43": { + | } + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin + + val response: String = + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl]) + .getMailboxId("#private", BOB.asString(), "myMailbox") + .serialize() + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [[ + | "Mailbox/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "created": { + | "C42": { + | "id": "$mailboxId", + | "isSubscribed":true, + | "myRights":{"mayAddItems":true,"mayCreateChild":true,"mayDelete":true,"mayReadItems":true,"mayRemoveItems":true,"mayRename":true,"maySetKeywords":true,"maySetSeen":true,"maySubmit":true}, + | "totalEmails":0, + | "totalThreads":0, + | "unreadEmails":0, + | "unreadThreads":0 + | } + | }, + | "notCreated": { + | "C43": { + | "type": "invalidArguments", + | "description": "Missing '/name' property in mailbox object" + | } + | } + | }, + | "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/MemoryMailboxSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMailboxSetMethodTest.java new file mode 100644 index 0000000..876c7ed --- /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/MemoryMailboxSetMethodTest.java @@ -0,0 +1,38 @@ +/**************************************************************** + * 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.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.jmap.rfc8621.contract.MailboxSetMethodContract; +import org.apache.james.modules.TestJMAPServerModule; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class MemoryMailboxSetMethodTest implements MailboxSetMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider()) + .server(configuration -> GuiceJamesServer.forConfiguration(configuration) + .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE) + .overrideWith(new TestJMAPServerModule())) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala index 09484a4..f15f1ce 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/Serializer.scala @@ -22,10 +22,13 @@ package org.apache.james.jmap.json import java.io.InputStream import java.net.URL +import eu.timepit.refined._ +import eu.timepit.refined.auto._ import javax.inject.Inject import org.apache.james.core.{Domain, Username} -import org.apache.james.jmap.mail.{DelegatedNamespace, Ids, IsSubscribed, Mailbox, MailboxGetRequest, MailboxGetResponse, MailboxNamespace, MailboxRights, MayAddItems, MayCreateChild, MayDelete, MayReadItems, MayRemoveItems, MayRename, MaySetKeywords, MaySetSeen, MaySubmit, NotFound, PersonalNamespace, Properties, Quota, QuotaId, QuotaRoot, Quotas, Right, Rights, SortOrder, TotalEmails, TotalThreads, UnreadEmails, UnreadThreads, Value} +import org.apache.james.jmap.mail.MailboxSetRequest.MailboxCreationId +import org.apache.james.jmap.mail.{DelegatedNamespace, Ids, IsSubscribed, Mailbox, MailboxCreationRequest, MailboxCreationResponse, MailboxGetRequest, MailboxGetResponse, MailboxNamespace, MailboxPatchObject, MailboxRights, MailboxSetError, MailboxSetRequest, MailboxSetResponse, MailboxUpdateResponse, MayAddItems, MayCreateChild, MayDelete, MayReadItems, MayRemoveItems, MayRename, MaySetKeywords, MaySetSeen, MaySubmit, NotFound, PersonalNamespace, Properties, Quota, QuotaId, QuotaRoot, Q [...] import org.apache.james.jmap.model import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier import org.apache.james.jmap.model.Invocation.{Arguments, MethodCallId, MethodName} @@ -150,7 +153,7 @@ class Serializer @Inject() (mailboxIdFactory: MailboxId.Factory) { case _ => JsError() } - private implicit val roleWrites: Writes[Role] = role => JsString(role.serialize) + private implicit val roleWrites: Writes[Role] = Writes(role => JsString(role.serialize)) private implicit val sortOrderWrites: Writes[SortOrder] = Json.valueWrites[SortOrder] private implicit val totalEmailsWrites: Writes[TotalEmails] = Json.valueWrites[TotalEmails] private implicit val unreadEmailsWrites: Writes[UnreadEmails] = Json.valueWrites[UnreadEmails] @@ -219,11 +222,84 @@ class Serializer @Inject() (mailboxIdFactory: MailboxId.Factory) { private implicit val propertiesRead: Reads[Properties] = Json.valueReads[Properties] private implicit val mailboxGetRequest: Reads[MailboxGetRequest] = Json.reads[MailboxGetRequest] + + private implicit val mailboxRemoveEmailsOnDestroy: Reads[RemoveEmailsOnDestroy] = Json.reads[RemoveEmailsOnDestroy] + implicit val mailboxCreationRequest: Reads[MailboxCreationRequest] = Json.reads[MailboxCreationRequest] + private implicit val mailboxPatchObject: Reads[MailboxPatchObject] = Json.valueReads[MailboxPatchObject] + + private implicit val mapPatchObjectByMailboxIdReads: Reads[Map[MailboxId, MailboxPatchObject]] = _.validate[Map[String, MailboxPatchObject]] + .map(mapWithStringKey => mapWithStringKey + .map(keyValue => (mailboxIdFactory.fromString(keyValue._1), keyValue._2))) + + private implicit val mapCreationRequestByMailBoxCreationId: Reads[Map[MailboxCreationId, JsObject]] = _.validate[Map[String, JsObject]] + .flatMap(mapWithStringKey => { + mapWithStringKey + .foldLeft[Either[JsError, Map[MailboxCreationId, JsObject]]](scala.util.Right[JsError, Map[MailboxCreationId, JsObject]](Map.empty))((acc: Either[JsError, Map[MailboxCreationId, JsObject]], keyValue) => { + acc match { + case error@Left(_) => error + case scala.util.Right(validatedAcc) => + val refinedKey: Either[String, MailboxCreationId] = refineV(keyValue._1) + refinedKey match { + case Left(error) => Left(JsError(error)) + case scala.util.Right(mailboxCreationId) => scala.util.Right(validatedAcc + (mailboxCreationId -> keyValue._2)) + } + } + }) match { + case Left(jsError) => jsError + case scala.util.Right(value) => JsSuccess(value) + } + }) + + private implicit val mailboxSetRequestReads: Reads[MailboxSetRequest] = Json.reads[MailboxSetRequest] + private implicit def notFoundWrites(implicit mailboxIdWrites: Writes[MailboxId]): Writes[NotFound] = notFound => JsArray(notFound.value.toList.map(mailboxIdWrites.writes)) private implicit def mailboxGetResponseWrites(implicit mailboxWrites: Writes[Mailbox]): Writes[MailboxGetResponse] = Json.writes[MailboxGetResponse] + private implicit val mailboxSetResponseWrites: Writes[MailboxSetResponse] = Json.writes[MailboxSetResponse] + + + private implicit val mailboxSetCreationResponseWrites: Writes[MailboxCreationResponse] = Json.writes[MailboxCreationResponse] + + private implicit val mailboxSetUpdateResponseWrites: Writes[MailboxUpdateResponse] = Json.writes[MailboxUpdateResponse] + + private implicit val propertiesWrites: Writes[Properties] = Json.writes[Properties] + + private implicit val setErrorDescriptionWrites: Writes[SetErrorDescription] = Json.valueWrites[SetErrorDescription] + + private implicit val mailboxSetErrorWrites: Writes[MailboxSetError] = Json.writes[MailboxSetError] + + private implicit def mailboxMapSetErrorForCreationWrites: Writes[Map[MailboxCreationId, MailboxSetError]] = + (m: Map[MailboxCreationId, MailboxSetError]) => { + m.foldLeft(JsObject.empty)((jsObject, kv) => { + val (mailboxCreationId: MailboxCreationId, mailboxSetError: MailboxSetError) = kv + jsObject.+(mailboxCreationId, mailboxSetErrorWrites.writes(mailboxSetError)) + }) + } + private implicit def mailboxMapSetErrorWrites: Writes[Map[MailboxId, MailboxSetError]] = + (m: Map[MailboxId, MailboxSetError]) => { + m.foldLeft(JsObject.empty)((jsObject, kv) => { + val (mailboxId: MailboxId, mailboxSetError: MailboxSetError) = kv + jsObject.+(mailboxId.serialize(), mailboxSetErrorWrites.writes(mailboxSetError)) + }) + } + + private implicit def mailboxMapCreationResponseWrites: Writes[Map[MailboxCreationId, MailboxCreationResponse]] = + (m: Map[MailboxCreationId, MailboxCreationResponse]) => { + m.foldLeft(JsObject.empty)((jsObject, kv) => { + val (mailboxCreationId: MailboxCreationId, mailboxCreationResponse: MailboxCreationResponse) = kv + jsObject.+(mailboxCreationId, mailboxSetCreationResponseWrites.writes(mailboxCreationResponse)) + }) + } + private implicit def mailboxMapUpdateResponseWrites: Writes[Map[MailboxId, MailboxUpdateResponse]] = + (m: Map[MailboxId, MailboxUpdateResponse]) => { + m.foldLeft(JsObject.empty)((jsObject, kv) => { + val (mailboxId: MailboxId, mailboxUpdateResponse: MailboxUpdateResponse) = kv + jsObject.+(mailboxId.serialize(), mailboxSetUpdateResponseWrites.writes(mailboxUpdateResponse)) + }) + } + private implicit val jsonValidationErrorWrites: Writes[JsonValidationError] = error => JsString(error.message) private implicit def jsonValidationErrorsWrites(implicit jsonValidationErrorWrites: Writes[JsonValidationError]): Writes[LegacySeq[JsonValidationError]] = @@ -261,9 +337,10 @@ class Serializer @Inject() (mailboxIdFactory: MailboxId.Factory) { def serialize(mailboxGetResponse: MailboxGetResponse)(implicit mailboxWrites: Writes[Mailbox]): JsValue = Json.toJson(mailboxGetResponse) - def serialize(mailboxGetResponse: MailboxGetResponse, properties: Option[Properties], capabilities: Set[CapabilityIdentifier]): JsValue = { + def serialize(mailboxGetResponse: MailboxGetResponse, properties: Option[Properties], capabilities: Set[CapabilityIdentifier]): JsValue = serialize(mailboxGetResponse)(mailboxWritesWithFilteredProperties(properties, capabilities)) - } + + def serialize(mailboxSetResponse: MailboxSetResponse): JsValue = Json.toJson(mailboxSetResponse)(mailboxSetResponseWrites) def serialize(errors: JsError): JsValue = Json.toJson(errors) @@ -276,4 +353,6 @@ class Serializer @Inject() (mailboxIdFactory: MailboxId.Factory) { def deserializeMailboxGetRequest(input: String): JsResult[MailboxGetRequest] = Json.parse(input).validate[MailboxGetRequest] def deserializeMailboxGetRequest(input: JsValue): JsResult[MailboxGetRequest] = Json.fromJson[MailboxGetRequest](input) + + def deserializeMailboxSetRequest(input: JsValue): JsResult[MailboxSetRequest] = Json.fromJson[MailboxSetRequest](input) } \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxSet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxSet.scala new file mode 100644 index 0000000..07517dc --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MailboxSet.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.mail + +import eu.timepit.refined.api.Refined +import eu.timepit.refined.collection.NonEmpty +import org.apache.james.jmap.mail.MailboxName.MailboxName +import org.apache.james.jmap.mail.MailboxSetRequest.MailboxCreationId +import org.apache.james.jmap.model.AccountId +import org.apache.james.jmap.model.State.State +import org.apache.james.mailbox.Role +import org.apache.james.mailbox.model.MailboxId +import play.api.libs.json.JsObject + +case class MailboxSetRequest(accountId: AccountId, + ifInState: Option[State], + create: Option[Map[MailboxCreationId, JsObject]], + update: Option[Map[MailboxId, MailboxPatchObject]], + destroy: Option[Seq[MailboxId]], + onDestroyRemoveEmails: Option[RemoveEmailsOnDestroy]) + +object MailboxSetRequest { + type MailboxCreationId = String Refined NonEmpty +} + +case class RemoveEmailsOnDestroy(value: Boolean) extends AnyVal +case class MailboxCreationRequest(name: MailboxName, parentId: Option[MailboxId]) + +case class MailboxPatchObject(value: Map[String, JsObject]) + +case class MailboxSetResponse(accountId: AccountId, + oldState: Option[State], + newState: State, + created: Option[Map[MailboxCreationId, MailboxCreationResponse]], + updated: Option[Map[MailboxId, MailboxUpdateResponse]], + destroyed: Option[Seq[MailboxId]], + notCreated: Option[Map[MailboxCreationId, MailboxSetError]], + notUpdated: Option[Map[MailboxId, MailboxSetError]], + notDestroyed: Option[Map[MailboxId, MailboxSetError]]) + +case class MailboxSetError(`type`: SetErrorType, description: Option[SetErrorDescription], properties: Option[Properties]) + +case class MailboxCreationResponse(id: MailboxId, + role: Option[Role],//TODO see if we need to return this, if a role is set by the server during creation + totalEmails: TotalEmails, + unreadEmails: UnreadEmails, + totalThreads: TotalThreads, + unreadThreads: UnreadThreads, + myRights: MailboxRights, + rights: Option[Rights],//TODO display only if RightsExtension and if some rights are set by the server during creation + namespace: Option[MailboxNamespace], //TODO display only if RightsExtension + quotas: Option[Quotas],//TODO display only if QuotasExtension + isSubscribed: IsSubscribed + ) + +case class MailboxUpdateResponse(value: JsObject) \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/package.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/package.scala new file mode 100644 index 0000000..981e224 --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/package.scala @@ -0,0 +1,28 @@ +/** ************************************************************** + * 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 + +import eu.timepit.refined.api.Refined +import eu.timepit.refined.collection.NonEmpty + + +package object mail { + type SetErrorType = String Refined NonEmpty + case class SetErrorDescription(description: String) extends AnyVal +} diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MailboxSetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MailboxSetMethod.scala new file mode 100644 index 0000000..86479c0 --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MailboxSetMethod.scala @@ -0,0 +1,123 @@ +/**************************************************************** + * 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 javax.inject.Inject +import org.apache.james.jmap.json.Serializer +import org.apache.james.jmap.mail.MailboxSetRequest.MailboxCreationId +import org.apache.james.jmap.mail.{IsSubscribed, MailboxCreationRequest, MailboxCreationResponse, MailboxRights, MailboxSetError, MailboxSetRequest, MailboxSetResponse, SetErrorDescription, TotalEmails, TotalThreads, UnreadEmails, UnreadThreads} +import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier +import org.apache.james.jmap.model.Invocation.{Arguments, MethodName} +import org.apache.james.jmap.model.{Invocation, State} +import org.apache.james.mailbox.model.{MailboxId, MailboxPath} +import org.apache.james.mailbox.{MailboxManager, MailboxSession} +import org.apache.james.metrics.api.MetricFactory +import org.reactivestreams.Publisher +import play.api.libs.json.{JsError, JsObject, JsPath, JsSuccess, Json, JsonValidationError} +import reactor.core.scala.publisher.{SFlux, SMono} +import reactor.core.scheduler.Schedulers + +import scala.collection.immutable + +class MailboxSetMethod @Inject() (serializer: Serializer, + mailboxManager: MailboxManager, + metricFactory: MetricFactory) extends Method { + override val methodName: MethodName = MethodName("Mailbox/set") + + + override def process(capabilities: Set[CapabilityIdentifier], invocation: Invocation, mailboxSession: MailboxSession): Publisher[Invocation] = { + metricFactory.decoratePublisherWithTimerMetricLogP99(JMAP_RFC8621_PREFIX + methodName.value, + asMailboxSetRequest(invocation.arguments) + .flatMap(mailboxSetRequest => { + val (unparsableCreateRequests, createRequests) = parseCreateRequests(mailboxSetRequest) + for { + created <- createMailboxes(mailboxSession, createRequests) + } yield createResponse(invocation, mailboxSetRequest, unparsableCreateRequests, created) + })) + } + + private def parseCreateRequests(mailboxSetRequest: MailboxSetRequest): (immutable.Iterable[(MailboxCreationId, MailboxSetError)], immutable.Iterable[(MailboxCreationId, MailboxCreationRequest)]) = { + mailboxSetRequest.create + .getOrElse(Map.empty) + .view + .mapValues(value => Json.fromJson(value)(serializer.mailboxCreationRequest)) + .toMap + .partitionMap { case (creationId, creationRequestParseResult) => + creationRequestParseResult match { + case JsSuccess(creationRequest, _) => Right((creationId, creationRequest)) + case JsError(errors) => Left(creationId, mailboxSetError(errors)) + } + } + } + + private def mailboxSetError(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]): MailboxSetError = + errors.head match { + case (path, Seq()) => MailboxSetError("invalidArguments", Some(SetErrorDescription(s"'$path' property in mailbox object is not valid")), None) + case (path, Seq(JsonValidationError(Seq("error.path.missing")))) => MailboxSetError("invalidArguments", Some(SetErrorDescription(s"Missing '$path' property in mailbox object")), None) + case (path, _) => MailboxSetError("invalidArguments", Some(SetErrorDescription(s"Unknown error on property '$path'")), None) + } + + private def createMailboxes(mailboxSession: MailboxSession, createRequests: immutable.Iterable[(MailboxCreationId, MailboxCreationRequest)]): SMono[Map[MailboxCreationId, MailboxId]] = { + SFlux.fromIterable(createRequests).flatMap { + case (mailboxCreationId: MailboxCreationId, mailboxCreationRequest: MailboxCreationRequest) => { + SMono.fromCallable(() => { + val path = MailboxPath.forUser(mailboxSession.getUser, mailboxCreationRequest.name) + //can safely do a get as the Optional is empty only if the mailbox name is empty which is forbidden by the type constraint on MailboxName + (mailboxCreationId, mailboxManager.createMailbox(path, mailboxSession).get()) + }).subscribeOn(Schedulers.elastic()) + } + }.collectMap(_._1, _._2) + } + + private def createResponse(invocation: Invocation, mailboxSetRequest: MailboxSetRequest, createErrors: immutable.Iterable[(MailboxCreationId, MailboxSetError)], created: Map[MailboxCreationId, MailboxId]): Invocation = { + Invocation(methodName, Arguments(serializer.serialize(MailboxSetResponse( + mailboxSetRequest.accountId, + oldState = None, + newState = State.INSTANCE, + created = Some(created.map(creation => (creation._1, MailboxCreationResponse( + id = creation._2, + role = None, + totalEmails = TotalEmails(0L), + unreadEmails = UnreadEmails(0L), + totalThreads = TotalThreads(0L), + unreadThreads = UnreadThreads(0L), + myRights = MailboxRights.FULL, + rights = None, + namespace = None, + quotas = None, + isSubscribed = IsSubscribed(true) + + )))).filter(_.nonEmpty), + notCreated = Some(createErrors.toMap).filter(_.nonEmpty), + updated = None, + notUpdated = None, + destroyed = None, + notDestroyed = None + )).as[JsObject]), invocation.methodCallId) + } + + private def asMailboxSetRequest(arguments: Arguments): SMono[MailboxSetRequest] = { + serializer.deserializeMailboxSetRequest(arguments.value) match { + case JsSuccess(mailboxSetRequest, _) => SMono.just(mailboxSetRequest) + case errors: JsError => SMono.raiseError(new IllegalArgumentException(serializer.serialize(errors).toString)) + } + } +} diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala index 331d2f1..5875ad8 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/model/Invocation.scala @@ -35,8 +35,9 @@ object Invocation { case class MethodCallId(value: NonEmptyString) - def error(errorCode: ErrorCode, description: String, methodCallId: MethodCallId): Invocation = Invocation(MethodName("error"), - Arguments(JsObject(Map("type" -> JsString(errorCode.code), "description" -> JsString(description)))), + def error(errorCode: ErrorCode, description: Option[String], methodCallId: MethodCallId): Invocation = { + Invocation(MethodName("error"), + Arguments(JsObject(Seq("type" -> JsString(errorCode.code), "description" -> JsString(description.getOrElse(""))))), methodCallId) def error(errorCode: ErrorCode, methodCallId: MethodCallId): Invocation = Invocation(MethodName("error"), --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
