You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2021/11/24 01:43:37 UTC
[james-project] 01/08: JAMES-3534 Implement Identity/set create
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 4f357bad39383a397e1ec583bf670fedf72db441
Author: Quan Tran <hq...@linagora.com>
AuthorDate: Tue Nov 16 12:13:58 2021 +0700
JAMES-3534 Implement Identity/set create
---
.../jmap/api/identity/CustomIdentityDAO.scala | 2 +-
.../james/jmap/json/IdentitySerializer.scala | 37 +++++--
.../org/apache/james/jmap/mail/IdentitySet.scala | 76 +++++++++++++++
.../jmap/method/IdentitySetCreatePerformer.scala | 108 +++++++++++++++++++++
.../james/jmap/method/IdentitySetMethod.scala | 61 ++++++++++++
5 files changed, 275 insertions(+), 9 deletions(-)
diff --git a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/identity/CustomIdentityDAO.scala b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/identity/CustomIdentityDAO.scala
index 027aea7..bd30740 100644
--- a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/identity/CustomIdentityDAO.scala
+++ b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/identity/CustomIdentityDAO.scala
@@ -25,7 +25,7 @@ import java.util.UUID
import com.google.common.collect.ImmutableList
import javax.inject.Inject
import org.apache.james.core.{MailAddress, Username}
-import org.apache.james.jmap.api.model.{EmailAddress, HtmlSignature, Identity, IdentityId, IdentityName, MayDeleteIdentity, TextSignature}
+import org.apache.james.jmap.api.model.{EmailAddress, HtmlSignature, Identity, IdentityId, IdentityName, MayDeleteIdentity, PushSubscriptionCreationRequest, TextSignature}
import org.apache.james.rrt.api.CanSendFrom
import org.reactivestreams.Publisher
import reactor.core.scala.publisher.{SFlux, SMono}
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 724be33..b1d194c 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
@@ -19,26 +19,45 @@
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.model.{EmailAddress, EmailerName, HtmlSignature, Identity, IdentityId, IdentityName, MayDeleteIdentity, TextSignature}
-import org.apache.james.jmap.core.{Properties, UuidState}
+import org.apache.james.jmap.core.Id.IdConstraint
+import org.apache.james.jmap.core.{Properties, SetError, UuidState}
import org.apache.james.jmap.mail._
-import play.api.libs.json.{Format, JsArray, JsObject, JsResult, JsSuccess, JsValue, Json, OWrites, Reads, Writes, __}
+import play.api.libs.json.{Format, JsArray, JsError, JsObject, JsResult, JsSuccess, JsValue, Json, OWrites, Reads, Writes, __}
object IdentitySerializer {
- private implicit val emailerNameReads: Writes[EmailerName] = Json.valueWrites[EmailerName]
+ private implicit val emailerNameReads: Format[EmailerName] = Json.valueFormat[EmailerName]
private implicit val identityIdFormat: Format[IdentityId] = Json.valueFormat[IdentityId]
private implicit val identityIdUnparsedFormat: Format[UnparsedIdentityId] = Json.valueFormat[UnparsedIdentityId]
private implicit val identityIdsFormat: Format[IdentityIds] = Json.valueFormat[IdentityIds]
- private implicit val emailAddressReads: Writes[EmailAddress] = Json.writes[EmailAddress]
- private implicit val nameWrites: Writes[IdentityName] = Json.valueWrites[IdentityName]
- private implicit val textSignatureWrites: Writes[TextSignature] = Json.valueWrites[TextSignature]
- private implicit val htmlSignatureWrites: Writes[HtmlSignature] = Json.valueWrites[HtmlSignature]
+ private implicit val emailAddressReads: Format[EmailAddress] = Json.format[EmailAddress]
+ private implicit val nameWrites: Format[IdentityName] = Json.valueFormat[IdentityName]
+ private implicit val textSignatureWrites: Format[TextSignature] = Json.valueFormat[TextSignature]
+ private implicit val htmlSignatureWrites: Format[HtmlSignature] = Json.valueFormat[HtmlSignature]
private implicit val mayDeleteWrites: Writes[MayDeleteIdentity] = Json.valueWrites[MayDeleteIdentity]
private implicit val identityWrites: Writes[Identity] = Json.writes[Identity]
private implicit val identityGetRequestReads: Reads[IdentityGetRequest] = Json.reads[IdentityGetRequest]
private implicit val stateWrites: Writes[UuidState] = Json.valueWrites[UuidState]
private implicit val identityGetResponseWrites: OWrites[IdentityGetResponse] = Json.writes[IdentityGetResponse]
+ private implicit val identityCreationIdWrites: Writes[IdentityCreationId] = Json.valueWrites[IdentityCreationId]
+ private implicit val identityCreationResponseWrites: Writes[IdentityCreationResponse] = Json.writes[IdentityCreationResponse]
+ private implicit val identityMapCreationResponseWrites: Writes[Map[IdentityCreationId, IdentityCreationResponse]] =
+ 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 identitySetResponseWrites: OWrites[IdentitySetResponse] = Json.writes[IdentitySetResponse]
+
+ private implicit val mapCreationRequestByIdentityCreationId: Reads[Map[IdentityCreationId, JsObject]] =
+ Reads.mapReads[IdentityCreationId, JsObject] {string => refineV[IdConstraint](string)
+ .fold(e => JsError(s"identity creationId needs to match id constraints: $e"),
+ id => JsSuccess(IdentityCreationId(id)))
+ }
+ private implicit val identitySetRequestReads: Reads[IdentitySetRequest] = Json.reads[IdentitySetRequest]
+ private implicit val identityCreationRequest: Reads[IdentityCreationRequest] = Json.reads[IdentityCreationRequest]
+
def serialize(response: IdentityGetResponse, properties: Properties): JsObject = Json.toJsObject(response)
.transform((__ \ "list").json.update {
case JsArray(underlying) => JsSuccess(JsArray(underlying.map {
@@ -46,7 +65,9 @@ object IdentitySerializer {
case jsValue => jsValue
}))
}).get
+ def serialize(response: IdentitySetResponse): JsObject = Json.toJsObject(response)
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)
}
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
new file mode 100644
index 0000000..8fa32ac
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/IdentitySet.scala
@@ -0,0 +1,76 @@
+/****************************************************************
+ * 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.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.WithAccountId
+import play.api.libs.json.JsObject
+
+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] =
+ (jsObject.keys.intersect(serverSetProperty), jsObject.keys.diff(knownProperties)) match {
+ case (_, unknownProperties) if unknownProperties.nonEmpty =>
+ Left(IdentityCreationParseException(SetError.invalidArguments(
+ SetErrorDescription("Some unknown properties were specified"),
+ Some(toProperties(unknownProperties.toSet)))))
+ case (specifiedServerSetProperties, _) if specifiedServerSetProperties.nonEmpty =>
+ Left(IdentityCreationParseException(SetError.invalidArguments(
+ SetErrorDescription("Some server-set properties were specified"),
+ Some(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(_))
+ }))
+}
+
+case class IdentitySetRequest(accountId: AccountId,
+ create: Option[Map[IdentityCreationId, JsObject]]) extends WithAccountId
+
+case class IdentityCreationId(id: Id) {
+ def serialise: String = id.value
+}
+
+case class IdentityCreationResponse(id: IdentityId,
+ name: Option[IdentityName],
+ textSignature: Option[TextSignature],
+ htmlSignature: Option[HtmlSignature],
+ mayDelete: MayDeleteIdentity)
+
+case class IdentitySetResponse(accountId: AccountId,
+ oldState: Option[UuidState],
+ newState: UuidState,
+ created: Option[Map[IdentityCreationId, IdentityCreationResponse]],
+ notCreated: Option[Map[IdentityCreationId, SetError]])
+
+case class IdentityCreationParseException(setError: SetError) extends IllegalArgumentException
\ 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
new file mode 100644
index 0000000..13d567a
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetCreatePerformer.scala
@@ -0,0 +1,108 @@
+/****************************************************************
+ * 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.api.identity.{IdentityCreationRequest, IdentityRepository}
+import org.apache.james.jmap.api.model.{ForbiddenSendFromException, HtmlSignature, Identity, IdentityName, TextSignature}
+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.method.IdentitySetCreatePerformer.{CreationFailure, CreationResult, CreationResults, CreationSuccess}
+import org.apache.james.mailbox.MailboxSession
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+import reactor.core.scala.publisher.{SFlux, SMono}
+import reactor.core.scheduler.Schedulers
+
+object IdentitySetCreatePerformer {
+ case class CreationResults(results: Seq[CreationResult]) {
+ def created: Option[Map[IdentityCreationId, IdentityCreationResponse]] =
+ Option(results.flatMap {
+ case result: CreationSuccess => Some((result.clientId, result.response))
+ case _ => None
+ }.toMap)
+ .filter(_.nonEmpty)
+
+ def notCreated: Option[Map[IdentityCreationId, SetError]] =
+ Option(results.flatMap {
+ case failure: CreationFailure => Some((failure.clientId, failure.asMessageSetError))
+ case _ => None
+ }
+ .toMap)
+ .filter(_.nonEmpty)
+ }
+
+ trait CreationResult
+
+ case class CreationSuccess(clientId: IdentityCreationId, response: IdentityCreationResponse) extends CreationResult
+
+ case class CreationFailure(clientId: IdentityCreationId, e: Throwable) extends CreationResult {
+ def asMessageSetError: SetError = e match {
+ case e: IdentityCreationParseException => e.setError
+ case e: ForbiddenSendFromException => SetError.forbiddenFrom(SetErrorDescription(e.getMessage))
+ case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(e.getMessage))
+ case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+ }
+ }
+}
+
+class IdentitySetCreatePerformer @Inject()(identityRepository: IdentityRepository) {
+ def create(request: IdentitySetRequest, mailboxSession: MailboxSession): SMono[CreationResults] =
+ SFlux.fromIterable(request.create.getOrElse(Map()))
+ .concatMap {
+ case (clientId, json) => parseCreate(json)
+ .fold(e => SMono.just[CreationResult](CreationFailure(clientId, e)),
+ creationRequest => create(clientId, creationRequest, mailboxSession))
+ }.collectSeq()
+ .map(CreationResults)
+
+ private def parseCreate(jsObject: JsObject): Either[Exception, IdentityCreationRequest] = for {
+ validJsObject <- IdentityCreation.validateProperties(jsObject)
+ parsedRequest <- IdentitySerializer.deserializeIdentityCreationRequest(validJsObject).asEither
+ .left.map(errors => IdentityCreationParseException(IdentitySetError(errors)))
+ } yield {
+ parsedRequest
+ }
+
+ private def create(clientId: IdentityCreationId, request: IdentityCreationRequest, mailboxSession: MailboxSession): SMono[CreationResult] =
+ SMono.fromPublisher(identityRepository.save(mailboxSession.getUser, request))
+ .map(identity => CreationSuccess(clientId, evaluateCreationResponse(request, identity)))
+ .onErrorResume(e => SMono.just[CreationResult](CreationFailure(clientId, e)))
+ .subscribeOn(Schedulers.elastic)
+
+ private def evaluateCreationResponse(request: IdentityCreationRequest, identity: Identity): IdentityCreationResponse =
+ IdentityCreationResponse(
+ id = identity.id,
+ name = request.name.fold[Option[IdentityName]](Some(IdentityName.DEFAULT))(_ => None),
+ 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
new file mode 100644
index 0000000..6138f2a
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetMethod.scala
@@ -0,0 +1,61 @@
+/****************************************************************
+ * 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.core.CapabilityIdentifier.{CapabilityIdentifier, EMAIL_SUBMISSION, JMAP_CORE}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.{Invocation, UuidState}
+import org.apache.james.jmap.json.{IdentitySerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.{IdentitySetRequest, IdentitySetResponse}
+import org.apache.james.jmap.routes.SessionSupplier
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.metrics.api.MetricFactory
+import play.api.libs.json.{JsError, JsSuccess}
+import reactor.core.scala.publisher.SMono
+
+class IdentitySetMethod @Inject()(createPerformer: IdentitySetCreatePerformer,
+ val metricFactory: MetricFactory,
+ val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[IdentitySetRequest] {
+ override val methodName: Invocation.MethodName = MethodName("Identity/set")
+ override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_CORE, EMAIL_SUBMISSION)
+
+ override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, IdentitySetRequest] =
+ IdentitySerializer.deserializeIdentitySetRequest(invocation.arguments.value) match {
+ case JsSuccess(identitySetRequest, _) => Right(identitySetRequest)
+ case errors: JsError => Left(new IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+ }
+
+ override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: IdentitySetRequest): SMono[InvocationWithContext] =
+ for {
+ creationResults <- createPerformer.create(request, mailboxSession)
+ } yield InvocationWithContext(
+ invocation = Invocation(
+ methodName = methodName,
+ arguments = Arguments(IdentitySerializer.serialize(IdentitySetResponse(
+ accountId = request.accountId,
+ oldState = None,
+ newState = UuidState.INSTANCE,
+ created = creationResults.created.filter(_.nonEmpty),
+ notCreated = creationResults.notCreated.filter(_.nonEmpty)))),
+ methodCallId = invocation.invocation.methodCallId),
+ processingContext = invocation.processingContext)
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org