From 1f32521ae397fe89402e24bbe6ae3aaa72322c97 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 25 Nov 2021 10:25:58 +0700 Subject: [PATCH] JAMES-3534 Implement Identity/set update (custom identities) (#754) --- .../contract/IdentitySetContract.scala | 452 +++++++++++++++++- .../james/jmap/json/IdentitySerializer.scala | 27 +- .../apache/james/jmap/mail/IdentitySet.scala | 54 ++- .../method/IdentitySetCreatePerformer.scala | 26 +- .../james/jmap/method/IdentitySetMethod.scala | 6 +- .../method/IdentitySetUpdatePerformer.scala | 105 ++++ 6 files changed, 618 insertions(+), 52 deletions(-) create mode 100644 server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetUpdatePerformer.scala 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/IdentitySetContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala index 31e4c7e64fc..2614aa434c5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala @@ -23,6 +23,8 @@ 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 net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER +import net.javacrumbs.jsonunit.core.internal.Options import org.apache.http.HttpStatus.SC_OK import org.apache.james.GuiceJamesServer import org.apache.james.jmap.core.ResponseObject.SESSION_STATE @@ -32,6 +34,8 @@ import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HE import org.apache.james.utils.DataProbeImpl import org.junit.jupiter.api.{BeforeEach, Test} +import java.util.UUID + trait IdentitySetContract { @BeforeEach def setUp(server: GuiceJamesServer): Unit = { @@ -43,6 +47,7 @@ trait IdentitySetContract { requestSpecification = baseRequestSpecBuilder(server) .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .build } @@ -101,7 +106,7 @@ trait IdentitySetContract { .when(net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER) .isEqualTo( s"""{ - | "sessionState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "sessionState": "${SESSION_STATE.value}", | "methodResponses": [ | [ | "Identity/set", @@ -274,7 +279,6 @@ trait IdentitySetContract { |}""".stripMargin val response = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) .when .post @@ -530,7 +534,6 @@ trait IdentitySetContract { |}""".stripMargin val response = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) .when .post @@ -550,10 +553,7 @@ trait IdentitySetContract { | "notCreated": { | "4f29": { | "type": "invalidArguments", - | "description": "Missing '/email' property in Identity object", - | "properties": [ - | "email" - | ] + | "description": "Missing '/email' property in Identity object" | } | } |}""".stripMargin) @@ -592,7 +592,6 @@ trait IdentitySetContract { |}""".stripMargin val response = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) .when .post @@ -642,7 +641,6 @@ trait IdentitySetContract { |}""".stripMargin val response = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) .when .post @@ -688,7 +686,6 @@ trait IdentitySetContract { |}""".stripMargin val response = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) .when .post @@ -733,7 +730,6 @@ trait IdentitySetContract { |}""".stripMargin val response: String = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) .body(request) .when .post @@ -761,4 +757,438 @@ trait IdentitySetContract { |}""".stripMargin) } + @Test + def updateShouldSucceed(): Unit = { + val identityId: String = createNewIdentity() + val response: String = `given` + .body( + s"""{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission" ], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "update": { + | "$identityId": { + | "name": "NewName1" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "updated": { + | "$identityId": {} + | } + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + } + + @Test + def updateShouldModifyIdentityEntry(): Unit = { + val identityId: String = createNewIdentity() + val response: String = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:submission" + | ], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "update": { + | "$identityId": { + | "name": "NewName1", + | "replyTo": [ + | { + | "name": "Difference Alice", + | "email": "alice2@domain.tld" + | } + | ], + | "bcc": [ + | { + | "name": "Difference David", + | "email": "david2@domain.tld" + | } + | ], + | "textSignature": "Difference text signature", + | "htmlSignature": "

Difference html signature

" + | } + | } + | }, + | "c1" + | ], + | [ + | "Identity/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": [ + | "$identityId" + | ] + | }, + | "c2" + | ] + | ] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "updated": { + | "$identityId": { } + | } + | }, + | "c1" + | ], + | [ + | "Identity/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "list": [ + | { + | "id": "$identityId", + | "name": "NewName1", + | "email": "bob@domain.tld", + | "replyTo": [ + | { + | "name": "Difference Alice", + | "email": "alice2@domain.tld" + | } + | ], + | "bcc": [ + | { + | "name": "Difference David", + | "email": "david2@domain.tld" + | } + | ], + | "textSignature": "Difference text signature", + | "htmlSignature": "

Difference html signature

", + | "mayDelete": true + | } + | ] + | }, + | "c2" + | ] + | ] + |}""".stripMargin) + } + + @Test + def updateShouldNotUpdatedWhenIdNotfound(): Unit = { + val notfoundIdentityId: String = UUID.randomUUID().toString + val response: String = `given` + .body( + s"""{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission" ], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "update": { + | "$notfoundIdentityId": { + | "name": "NewName1" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notUpdated": { + | "$notfoundIdentityId": { + | "type": "notFound", + | "description": "IdentityId($notfoundIdentityId) could not be found" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def updateShouldNotUpdatedWhenIdNotParsed(): Unit = { + val notParsedId: String = "k123" + val response: String = `given` + .body( + s"""{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission" ], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "update": { + | "$notParsedId": { + | "name": "NewName1" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notUpdated": { + | "$notParsedId": { + | "type": "invalidArguments", + | "description": "Invalid UUID string: $notParsedId" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def updateShouldNotUpdatedWhenAssignServerSetProperty(): Unit = { + val identityId: String = createNewIdentity() + val response: String = `given` + .body( + s"""{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission" ], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "update": { + | "$identityId": { + | "email": "bob2@domain.tld" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notUpdated": { + | "$identityId": { + | "type": "invalidArguments", + | "description": "Some server-set properties were specified", + | "properties": [ + | "email" + | ] + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test + def updateShouldSuccessWhenMixed(): Unit = { + val updateIdentityId1: String = createNewIdentity() + val updateIdentityId2: String = createNewIdentity() + val notUpdateIdentityId1: String = UUID.randomUUID().toString + val notUpdateIdentityId2: String = "notParsedId" + + val response: String = `given` + .body( + s"""{ + | "using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission" ], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "update": { + | "$updateIdentityId1": { "name": "new Name 1" }, + | "$updateIdentityId2": { "name": "new Name 2" }, + | "$notUpdateIdentityId1": { "name": "new Name 3" }, + | "$notUpdateIdentityId2": { "name": "new Name 4" } + | } + | }, + | "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": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "updated": { + | "$updateIdentityId1": {}, + | "$updateIdentityId2": {} + | }, + | "notUpdated": { + | "$notUpdateIdentityId1": { + | "type": "notFound", + | "description": "IdentityId($notUpdateIdentityId1) could not be found" + | }, + | "$notUpdateIdentityId2": { + | "type": "invalidArguments", + | "description": "Invalid UUID string: $notUpdateIdentityId2" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + private def createNewIdentity(): String = createNewIdentity(UUID.randomUUID().toString) + + private def createNewIdentity(clientId: String): String = + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body( + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "$clientId": { + | "name": "Bob", + | "email": "bob@domain.tld", + | "replyTo": [{ + | "name": "Alice", + | "email": "alice@domain.tld" + | }], + | "bcc": [{ + | "name": "David", + | "email": "david@domain.tld" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "

Some html signature

" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .extract() + .jsonPath() + .get(s"methodResponses[0][1].created.$clientId.id") } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/IdentitySerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/IdentitySerializer.scala index b1d194c9d1e..171c7df4ae8 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/IdentitySerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/IdentitySerializer.scala @@ -20,11 +20,12 @@ package org.apache.james.jmap.json import eu.timepit.refined.refineV -import org.apache.james.jmap.api.identity.IdentityCreationRequest +import org.apache.james.jmap.api.identity.{IdentityBccUpdate, IdentityCreationRequest, IdentityHtmlSignatureUpdate, IdentityNameUpdate, IdentityReplyToUpdate, IdentityTextSignatureUpdate, IdentityUpdateRequest} import org.apache.james.jmap.api.model.{EmailAddress, EmailerName, HtmlSignature, Identity, IdentityId, IdentityName, MayDeleteIdentity, TextSignature} import org.apache.james.jmap.core.Id.IdConstraint import org.apache.james.jmap.core.{Properties, SetError, UuidState} import org.apache.james.jmap.mail._ +import org.apache.james.jmap.method.IdentitySetUpdatePerformer.IdentitySetUpdateResponse import play.api.libs.json.{Format, JsArray, JsError, JsObject, JsResult, JsSuccess, JsValue, Json, OWrites, Reads, Writes, __} object IdentitySerializer { @@ -48,6 +49,11 @@ object IdentitySerializer { mapWrites[IdentityCreationId, IdentityCreationResponse](id => identityCreationIdWrites.writes(id).as[String], identityCreationResponseWrites) private implicit val identityMapSetErrorForCreationWrites: Writes[Map[IdentityCreationId, SetError]] = mapWrites[IdentityCreationId, SetError](_.serialise, setErrorWrites) + private implicit val unparsedIdentityMapSetErrorForCreationWrites: Writes[Map[UnparsedIdentityId, SetError]] = + mapWrites[UnparsedIdentityId, SetError](_.id.value, setErrorWrites) + private implicit val identitySetUpdateResponseWrites: Writes[IdentitySetUpdateResponse] = Json.valueWrites[IdentitySetUpdateResponse] + private implicit val mapIdentitySetUpdateResponseWrites: Writes[Map[IdentityId, IdentitySetUpdateResponse]] = + mapWrites[IdentityId, IdentitySetUpdateResponse](_.id.toString, identitySetUpdateResponseWrites) private implicit val identitySetResponseWrites: OWrites[IdentitySetResponse] = Json.writes[IdentitySetResponse] private implicit val mapCreationRequestByIdentityCreationId: Reads[Map[IdentityCreationId, JsObject]] = @@ -55,8 +61,26 @@ object IdentitySerializer { .fold(e => JsError(s"identity creationId needs to match id constraints: $e"), id => JsSuccess(IdentityCreationId(id))) } + + private implicit val mapUnparsedIdentitySetIdAndRequest: Reads[Map[UnparsedIdentityId, JsObject]] = + Reads.mapReads[UnparsedIdentityId, JsObject] {string => refineV[IdConstraint](string) + .fold(e => JsError(s"Identity Id needs to match id constraints: $e"), + id => JsSuccess(UnparsedIdentityId(id))) + } + + implicit def optionReads[T: Reads]: Reads[Option[T]] = (json: JsValue) => json.validateOpt[T] + private implicit val identityNameUpdateReads: Reads[IdentityNameUpdate] = Json.valueReads[IdentityNameUpdate] + private implicit val identityReplyToUpdateReads2: Reads[EmailAddress] = Json.reads[EmailAddress] + + private implicit val identityReplyToUpdateReads: Reads[IdentityReplyToUpdate] = Json.valueReads[IdentityReplyToUpdate] + private implicit val identityBccUpdateReads: Reads[IdentityBccUpdate] = Json.valueReads[IdentityBccUpdate] + + private implicit val identityTextSignatureUpdateReads: Reads[IdentityTextSignatureUpdate] = Json.valueReads[IdentityTextSignatureUpdate] + private implicit val identityHtmlSignatureUpdateReads: Reads[IdentityHtmlSignatureUpdate] = Json.valueReads[IdentityHtmlSignatureUpdate] + private implicit val identitySetRequestReads: Reads[IdentitySetRequest] = Json.reads[IdentitySetRequest] private implicit val identityCreationRequest: Reads[IdentityCreationRequest] = Json.reads[IdentityCreationRequest] + private implicit val identityUpdateRequest: Reads[IdentityUpdateRequest]= Json.reads[IdentityUpdateRequest] def serialize(response: IdentityGetResponse, properties: Properties): JsObject = Json.toJsObject(response) .transform((__ \ "list").json.update { @@ -70,4 +94,5 @@ object IdentitySerializer { def deserialize(input: JsValue): JsResult[IdentityGetRequest] = Json.fromJson[IdentityGetRequest](input) def deserializeIdentitySetRequest(input: JsValue): JsResult[IdentitySetRequest] = Json.fromJson[IdentitySetRequest](input) def deserializeIdentityCreationRequest(input: JsValue): JsResult[IdentityCreationRequest] = Json.fromJson[IdentityCreationRequest](input) + def deserializeIdentityUpdateRequest(input: JsValue): JsResult[IdentityUpdateRequest] = Json.fromJson[IdentityUpdateRequest](input) } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/IdentitySet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/IdentitySet.scala index 8fa32acda48..799c0a2c19e 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/IdentitySet.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/IdentitySet.scala @@ -19,43 +19,38 @@ package org.apache.james.jmap.mail -import eu.timepit.refined.collection.NonEmpty -import eu.timepit.refined.refineV -import eu.timepit.refined.types.string.NonEmptyString import org.apache.james.jmap.api.model.{HtmlSignature, IdentityId, IdentityName, MayDeleteIdentity, TextSignature} import org.apache.james.jmap.core.Id.Id import org.apache.james.jmap.core.SetError.SetErrorDescription import org.apache.james.jmap.core.{AccountId, Properties, SetError, UuidState} +import org.apache.james.jmap.method.IdentitySetUpdatePerformer.IdentitySetUpdateResponse import org.apache.james.jmap.method.WithAccountId -import play.api.libs.json.JsObject +import play.api.libs.json.{JsObject, JsPath, JsonValidationError} -object IdentityCreation { - private val serverSetProperty: Set[String] = Set("id", "mayDelete") - private val assignableProperties: Set[String] = Set("name", "email", "replyTo", "bcc", "textSignature", "htmlSignature") - private val knownProperties: Set[String] = assignableProperties ++ serverSetProperty - - def validateProperties(jsObject: JsObject): Either[IdentityCreationParseException, JsObject] = +object IdentitySet { + def validateProperties(serverSetProperty: Set[String], knownProperties: Set[String], jsObject: JsObject): Either[IdentitySetParseException, JsObject] = (jsObject.keys.intersect(serverSetProperty), jsObject.keys.diff(knownProperties)) match { case (_, unknownProperties) if unknownProperties.nonEmpty => - Left(IdentityCreationParseException(SetError.invalidArguments( + Left(IdentitySetParseException(SetError.invalidArguments( SetErrorDescription("Some unknown properties were specified"), - Some(toProperties(unknownProperties.toSet))))) + Some(Properties.toProperties(unknownProperties.toSet))))) case (specifiedServerSetProperties, _) if specifiedServerSetProperties.nonEmpty => - Left(IdentityCreationParseException(SetError.invalidArguments( + Left(IdentitySetParseException(SetError.invalidArguments( SetErrorDescription("Some server-set properties were specified"), - Some(toProperties(specifiedServerSetProperties.toSet))))) + Some(Properties.toProperties(specifiedServerSetProperties.toSet))))) case _ => scala.Right(jsObject) } +} - private def toProperties(strings: Set[String]): Properties = Properties(strings - .flatMap(string => { - val refinedValue: Either[String, NonEmptyString] = refineV[NonEmpty](string) - refinedValue.fold(_ => None, Some(_)) - })) +object IdentityCreation { + val serverSetProperty: Set[String] = Set("id", "mayDelete") + val assignableProperties: Set[String] = Set("name", "email", "replyTo", "bcc", "textSignature", "htmlSignature") + val knownProperties: Set[String] = assignableProperties ++ serverSetProperty } case class IdentitySetRequest(accountId: AccountId, - create: Option[Map[IdentityCreationId, JsObject]]) extends WithAccountId + create: Option[Map[IdentityCreationId, JsObject]], + update: Option[Map[UnparsedIdentityId, JsObject]]) extends WithAccountId case class IdentityCreationId(id: Id) { def serialise: String = id.value @@ -71,6 +66,21 @@ case class IdentitySetResponse(accountId: AccountId, oldState: Option[UuidState], newState: UuidState, created: Option[Map[IdentityCreationId, IdentityCreationResponse]], - notCreated: Option[Map[IdentityCreationId, SetError]]) + notCreated: Option[Map[IdentityCreationId, SetError]], + updated: Option[Map[IdentityId, IdentitySetUpdateResponse]], + notUpdated: Option[Map[UnparsedIdentityId, SetError]]) + +case class IdentitySetParseException(setError: SetError) extends IllegalArgumentException -case class IdentityCreationParseException(setError: SetError) extends IllegalArgumentException \ No newline at end of file +object IdentitySetParseException { + def from(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]): IdentitySetParseException = { + val setError: SetError = errors.head match { + case (path, Seq()) => SetError.invalidArguments(SetErrorDescription(s"'$path' property in Identity object is not valid")) + case (path, Seq(JsonValidationError(Seq("error.path.missing")))) => + SetError.invalidArguments(SetErrorDescription(s"Missing '$path' property in Identity object")) + case (path, Seq(JsonValidationError(Seq(message)))) => SetError.invalidArguments(SetErrorDescription(s"'$path' property in Identity object is not valid: $message")) + case (path, _) => SetError.invalidArguments(SetErrorDescription(s"Unknown error on property '$path'")) + } + IdentitySetParseException(setError) + } +} \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetCreatePerformer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetCreatePerformer.scala index 13d567a63ff..d557c85d14b 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetCreatePerformer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetCreatePerformer.scala @@ -19,20 +19,21 @@ package org.apache.james.jmap.method -import eu.timepit.refined.auto._ -import javax.inject.Inject import org.apache.james.jmap.api.identity.{IdentityCreationRequest, IdentityRepository} import org.apache.james.jmap.api.model.{ForbiddenSendFromException, HtmlSignature, Identity, IdentityName, TextSignature} +import org.apache.james.jmap.core.SetError import org.apache.james.jmap.core.SetError.SetErrorDescription -import org.apache.james.jmap.core.{Properties, SetError} import org.apache.james.jmap.json.IdentitySerializer -import org.apache.james.jmap.mail.{IdentityCreation, IdentityCreationId, IdentityCreationParseException, IdentityCreationResponse, IdentitySetRequest} +import org.apache.james.jmap.mail.IdentityCreation.{knownProperties, serverSetProperty} +import org.apache.james.jmap.mail.{IdentityCreationId, IdentityCreationResponse, IdentitySet, IdentitySetParseException, IdentitySetRequest} import org.apache.james.jmap.method.IdentitySetCreatePerformer.{CreationFailure, CreationResult, CreationResults, CreationSuccess} import org.apache.james.mailbox.MailboxSession -import play.api.libs.json.{JsObject, JsPath, JsonValidationError} +import play.api.libs.json.JsObject import reactor.core.scala.publisher.{SFlux, SMono} import reactor.core.scheduler.Schedulers +import javax.inject.Inject + object IdentitySetCreatePerformer { case class CreationResults(results: Seq[CreationResult]) { def created: Option[Map[IdentityCreationId, IdentityCreationResponse]] = @@ -57,7 +58,7 @@ object IdentitySetCreatePerformer { case class CreationFailure(clientId: IdentityCreationId, e: Throwable) extends CreationResult { def asMessageSetError: SetError = e match { - case e: IdentityCreationParseException => e.setError + case e: IdentitySetParseException => e.setError case e: ForbiddenSendFromException => SetError.forbiddenFrom(SetErrorDescription(e.getMessage)) case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(e.getMessage)) case _ => SetError.serverFail(SetErrorDescription(e.getMessage)) @@ -76,9 +77,9 @@ class IdentitySetCreatePerformer @Inject()(identityRepository: IdentityRepositor .map(CreationResults) private def parseCreate(jsObject: JsObject): Either[Exception, IdentityCreationRequest] = for { - validJsObject <- IdentityCreation.validateProperties(jsObject) + validJsObject <- IdentitySet.validateProperties(serverSetProperty, knownProperties, jsObject) parsedRequest <- IdentitySerializer.deserializeIdentityCreationRequest(validJsObject).asEither - .left.map(errors => IdentityCreationParseException(IdentitySetError(errors))) + .left.map(errors => IdentitySetParseException.from(errors)) } yield { parsedRequest } @@ -96,13 +97,4 @@ class IdentitySetCreatePerformer @Inject()(identityRepository: IdentityRepositor textSignature = request.textSignature.fold[Option[TextSignature]](Some(TextSignature.DEFAULT))(_ => None), htmlSignature = request.htmlSignature.fold[Option[HtmlSignature]](Some(HtmlSignature.DEFAULT))(_ => None), mayDelete = identity.mayDelete) - - private def IdentitySetError(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]): SetError = - errors.head match { - case (path, Seq()) => SetError.invalidArguments(SetErrorDescription(s"'$path' property in Identity object is not valid")) - case (path, Seq(JsonValidationError(Seq("error.path.missing")))) => - SetError.invalidArguments(SetErrorDescription(s"Missing '$path' property in Identity object"), Some(Properties("email"))) - case (path, Seq(JsonValidationError(Seq(message)))) => SetError.invalidArguments(SetErrorDescription(s"'$path' property in Identity object is not valid: $message")) - case (path, _) => SetError.invalidArguments(SetErrorDescription(s"Unknown error on property '$path'")) - } } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetMethod.scala index c68fc83a76b..11f2e44364c 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetMethod.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetMethod.scala @@ -33,6 +33,7 @@ import play.api.libs.json.{JsError, JsSuccess} import reactor.core.scala.publisher.SMono class IdentitySetMethod @Inject()(createPerformer: IdentitySetCreatePerformer, + updatePerformer: IdentitySetUpdatePerformer, val metricFactory: MetricFactory, val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[IdentitySetRequest] { override val methodName: Invocation.MethodName = MethodName("Identity/set") @@ -47,6 +48,7 @@ class IdentitySetMethod @Inject()(createPerformer: IdentitySetCreatePerformer, override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: IdentitySetRequest): SMono[InvocationWithContext] = for { creationResults <- createPerformer.create(request, mailboxSession) + updatedResults <- updatePerformer.update(request, mailboxSession) } yield InvocationWithContext( invocation = Invocation( methodName = methodName, @@ -55,7 +57,9 @@ class IdentitySetMethod @Inject()(createPerformer: IdentitySetCreatePerformer, oldState = None, newState = UuidState.INSTANCE, created = creationResults.created.filter(_.nonEmpty), - notCreated = creationResults.notCreated.filter(_.nonEmpty)))), + notCreated = creationResults.notCreated.filter(_.nonEmpty), + updated = Some(updatedResults.updated).filter(_.nonEmpty), + notUpdated = Some(updatedResults.notUpdated).filter(_.nonEmpty)))), methodCallId = invocation.invocation.methodCallId), processingContext = creationResults.created.getOrElse(Map()) .foldLeft(invocation.processingContext)({ diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetUpdatePerformer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetUpdatePerformer.scala new file mode 100644 index 00000000000..a4ef89d9d80 --- /dev/null +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetUpdatePerformer.scala @@ -0,0 +1,105 @@ +/**************************************************************** + * 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 org.apache.james.jmap.api.identity.{IdentityNotFoundException, IdentityRepository, IdentityUpdateRequest} +import org.apache.james.jmap.api.model.IdentityId +import org.apache.james.jmap.core.SetError +import org.apache.james.jmap.core.SetError.SetErrorDescription +import org.apache.james.jmap.json.IdentitySerializer +import org.apache.james.jmap.mail.{IdentitySet, IdentitySetParseException, IdentitySetRequest, InvalidPropertyException, UnparsedIdentityId} +import org.apache.james.jmap.method.IdentitySetUpdatePerformer.IdentityUpdate.{knownProperties, serverSetProperty} +import org.apache.james.jmap.method.IdentitySetUpdatePerformer.{IdentitySetUpdateResults, UpdateFailure, UpdateResult, UpdateSuccess} +import org.apache.james.mailbox.MailboxSession +import play.api.libs.json.{JsObject, JsValue} +import reactor.core.scala.publisher.{SFlux, SMono} + +import javax.inject.Inject + +object IdentitySetUpdatePerformer { + sealed trait UpdateResult + + case class UpdateSuccess(id: IdentityId) extends UpdateResult + + case class UpdateFailure(id: UnparsedIdentityId, exception: Throwable) extends UpdateResult { + def asSetError: SetError = exception match { + case e: IdentitySetParseException => e.setError + case e: IdentityNotFoundException => SetError.notFound(SetErrorDescription(e.getMessage)) + case e: InvalidPropertyException => SetError.invalidPatch(SetErrorDescription(e.getCause.getMessage)) + case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(e.getMessage), None) + case _ => SetError.serverFail(SetErrorDescription(exception.getMessage)) + } + } + + object IdentitySetUpdateResponse { + def empty: IdentitySetUpdateResponse = IdentitySetUpdateResponse(JsObject(Map[String, JsValue]())) + } + + case class IdentitySetUpdateResponse(value: JsObject) + + case class IdentitySetUpdateResults(results: Seq[UpdateResult]) { + def updated: Map[IdentityId, IdentitySetUpdateResponse] = + results.flatMap(result => result match { + case success: UpdateSuccess => Some((success.id, IdentitySetUpdateResponse.empty)) + case _ => None + }).toMap + + def notUpdated: Map[UnparsedIdentityId, SetError] = results.flatMap(result => result match { + case failure: UpdateFailure => Some(failure.id, failure.asSetError) + case _ => None + }).toMap + } + + object IdentityUpdate { + val serverSetProperty: Set[String] = Set("id", "mayDelete", "email") + val assignableProperties: Set[String] = Set("name", "replyTo", "bcc", "textSignature", "htmlSignature") + val knownProperties: Set[String] = assignableProperties ++ serverSetProperty + } +} + +class IdentitySetUpdatePerformer @Inject()(identityRepository: IdentityRepository) { + def update(request: IdentitySetRequest, mailboxSession: MailboxSession): SMono[IdentitySetUpdateResults] = + SFlux.fromIterable(request.update.getOrElse(Map())) + .concatMap { + case (unparsedId, json) => + val either: Either[Exception, SMono[UpdateResult]] = for { + identityId <- unparsedId.validate + updateRequest <- parseRequest(json) + } yield { + update(identityId, updateRequest, mailboxSession) + .onErrorResume(error => SMono.just[UpdateResult](UpdateFailure(unparsedId, error))) + } + either.fold(error => SMono.just[UpdateResult](UpdateFailure(unparsedId, error)), + updateRequestResult => updateRequestResult) + }.collectSeq() + .map(IdentitySetUpdateResults) + + private def parseRequest(jsObject: JsObject): Either[Exception, IdentityUpdateRequest] = for { + validJsObject <- IdentitySet.validateProperties(serverSetProperty, knownProperties, jsObject) + parsedRequest <- IdentitySerializer.deserializeIdentityUpdateRequest(validJsObject) + .asEither + .left.map(errors => IdentitySetParseException.from(errors)) + } yield parsedRequest + + private def update(identityId: IdentityId, updateRequest: IdentityUpdateRequest, mailboxSession: MailboxSession): SMono[UpdateResult] = + SMono.fromPublisher(identityRepository.update(mailboxSession.getUser, identityId, updateRequest)) + .`then`(SMono.just[UpdateResult](UpdateSuccess(identityId))) + +}