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 339a016f6608f38ce651aa4f0f958135c176d97f Author: Benoit Tellier <[email protected]> AuthorDate: Fri Oct 23 15:39:45 2020 +0700 JAMES-3436 Email/set create - mailboxIds & subject properties --- .../rfc8621/contract/EmailSetMethodContract.scala | 58 ++++++++++ .../james/jmap/json/EmailSetSerializer.scala | 18 +++- .../org/apache/james/jmap/mail/EmailSet.scala | 22 +++- .../apache/james/jmap/method/EmailSetMethod.scala | 118 +++++++++++++++------ 4 files changed, 179 insertions(+), 37 deletions(-) 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/EmailSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala index 2898b92..d8a3e14 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala @@ -175,6 +175,64 @@ trait EmailSetMethodContract { } @Test + def createShouldAddAnEmailInTargetMailbox(server: GuiceJamesServer): Unit = { + val bobPath = MailboxPath.inbox(BOB) + val mailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "create": { + | "aaaaaa":{ + | "mailboxIds": { + | "${mailboxId.serialize}": true + | }, + | "subject": "Boredome comes from a boring mind!" + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["#aaaaaa"], + | "properties": ["mailboxIds", "subject"] + | }, + | "c2"]] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .whenIgnoringPaths("methodResponses[0][1].created.aaaaaa.id") + .inPath("methodResponses[0][1].created.aaaaaa") + .isEqualTo("{}".stripMargin) + + assertThatJson(response) + .whenIgnoringPaths("methodResponses[1][1].list[0].id") + .inPath(s"methodResponses[1][1].list") + .isEqualTo( + s"""[{ + | "mailboxIds": { + | "${mailboxId.serialize}": true + | }, + | "subject": "Boredome comes from a boring mind!" + |}]""".stripMargin) + } + + @Test def shouldNotResetKeywordWhenInvalidKeyword(server: GuiceJamesServer): Unit = { val message: Message = Fixture.createTestMessage diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala index 2928c03..e36e3ca 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailSetSerializer.scala @@ -22,8 +22,9 @@ package org.apache.james.jmap.json import cats.implicits._ import eu.timepit.refined.refineV import javax.inject.Inject -import org.apache.james.jmap.mail.EmailSet.{UnparsedMessageId, UnparsedMessageIdConstraint} -import org.apache.james.jmap.mail.{DestroyIds, EmailSetRequest, EmailSetResponse, EmailSetUpdate, MailboxIds} +import org.apache.james.jmap.mail.EmailSet.{EmailCreationId, UnparsedMessageId, UnparsedMessageIdConstraint} +import org.apache.james.jmap.mail.{DestroyIds, EmailCreationRequest, EmailCreationResponse, EmailSetRequest, EmailSetResponse, EmailSetUpdate, MailboxIds, Subject} +import org.apache.james.jmap.model.Id.IdConstraint import org.apache.james.jmap.model.KeywordsFactory.STRICT_KEYWORDS_FACTORY import org.apache.james.jmap.model.{Keyword, Keywords, SetError} import org.apache.james.mailbox.model.{MailboxId, MessageId} @@ -192,6 +193,13 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI case _ => JsError("Expecting a JsObject as an update entry") }) + private implicit val createsMapReads: Reads[Map[EmailCreationId, JsObject]] = + readMapEntry[EmailCreationId, JsObject](s => refineV[IdConstraint](s), + { + case o: JsObject => JsSuccess(o) + case _ => JsError("Expecting a JsObject as an update entry") + }) + private implicit val keywordReads: Reads[Keyword] = { case jsString: JsString => Keyword.parse(jsString.value) .fold(JsError(_), @@ -218,10 +226,16 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI } private implicit val destroyIdsWrites: Writes[DestroyIds] = Json.valueWrites[DestroyIds] private implicit val emailRequestSetReads: Reads[EmailSetRequest] = Json.reads[EmailSetRequest] + private implicit val emailCreationResponseWrites: Writes[EmailCreationResponse] = Json.writes[EmailCreationResponse] private implicit val emailResponseSetWrites: OWrites[EmailSetResponse] = Json.writes[EmailSetResponse] + private implicit val subjectReads: Reads[Subject] = Json.valueReads[Subject] + private implicit val emailCreationRequestReads: Reads[EmailCreationRequest] = Json.reads[EmailCreationRequest] + def deserialize(input: JsValue): JsResult[EmailSetRequest] = Json.fromJson[EmailSetRequest](input) + def deserializeCreationRequest(input: JsValue): JsResult[EmailCreationRequest] = Json.fromJson[EmailCreationRequest](input) + def deserializeEmailSetUpdate(input: JsValue): JsResult[EmailSetUpdate] = Json.fromJson[EmailSetUpdate](input) def serialize(response: EmailSetResponse): JsObject = Json.toJsObject(response) diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala index ddeac39..5985147 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailSet.scala @@ -18,19 +18,24 @@ ****************************************************************/ package org.apache.james.jmap.mail +import java.nio.charset.StandardCharsets + import eu.timepit.refined import eu.timepit.refined.api.Refined import eu.timepit.refined.collection.NonEmpty -import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId +import org.apache.james.jmap.mail.EmailSet.{EmailCreationId, UnparsedMessageId} import org.apache.james.jmap.method.WithAccountId +import org.apache.james.jmap.model.Id.Id import org.apache.james.jmap.model.State.State import org.apache.james.jmap.model.{AccountId, Keywords, SetError} import org.apache.james.mailbox.model.MessageId +import org.apache.james.mime4j.dom.Message import play.api.libs.json.JsObject import scala.util.{Right, Try} object EmailSet { + type EmailCreationId = Id type UnparsedMessageIdConstraint = NonEmpty type UnparsedMessageId = String Refined UnparsedMessageIdConstraint @@ -43,14 +48,27 @@ object EmailSet { Try(messageIdFactory.fromString(unparsed.value)) } +case class EmailCreationRequest(mailboxIds: MailboxIds, + subject: Option[Subject]) { + def toMime4JMessage: Message = { + val builder = Message.Builder.of + subject.foreach(value => builder.setSubject(value.value)) + builder.setBody("", StandardCharsets.UTF_8) + builder.build() + } +} + case class DestroyIds(value: Seq[UnparsedMessageId]) case class EmailSetRequest(accountId: AccountId, + create: Option[Map[EmailCreationId, JsObject]], update: Option[Map[UnparsedMessageId, JsObject]], destroy: Option[DestroyIds]) extends WithAccountId case class EmailSetResponse(accountId: AccountId, newState: State, + created: Option[Map[EmailCreationId, EmailCreationResponse]], + notCreated: Option[Map[EmailCreationId, SetError]], updated: Option[Map[MessageId, Unit]], notUpdated: Option[Map[UnparsedMessageId, SetError]], destroyed: Option[DestroyIds], @@ -118,3 +136,5 @@ class EmailUpdateValidationException() extends IllegalArgumentException case class InvalidEmailPropertyException(property: String, cause: String) extends EmailUpdateValidationException case class InvalidEmailUpdateException(property: String, cause: String) extends EmailUpdateValidationException +case class EmailCreationResponse(id: MessageId) + diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala index 1d1b519..58f523d 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetMethod.scala @@ -28,16 +28,20 @@ import org.apache.james.jmap.http.SessionSupplier import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer} import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId import org.apache.james.jmap.mail.{DestroyIds, EmailSet, EmailSetRequest, EmailSetResponse, MailboxIds, ValidatedEmailSetUpdate} +import org.apache.james.jmap.mail.EmailSet.{EmailCreationId, UnparsedMessageId} +import org.apache.james.jmap.mail.{DestroyIds, EmailCreationRequest, EmailCreationResponse, EmailSet, EmailSetRequest, EmailSetResponse, EmailSetUpdate, MailboxIds, ValidatedEmailSetUpdate} import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier import org.apache.james.jmap.model.DefaultCapabilities.{CORE_CAPABILITY, MAIL_CAPABILITY} import org.apache.james.jmap.model.Invocation.{Arguments, MethodName} import org.apache.james.jmap.model.KeywordsFactory.LENIENT_KEYWORDS_FACTORY import org.apache.james.jmap.model.SetError.SetErrorDescription -import org.apache.james.jmap.model.{Capabilities, Invocation, SetError, State} -import org.apache.james.mailbox.MessageManager.FlagsUpdateMode +import org.apache.james.jmap.model.{Capabilities, ClientId, Id, Invocation, ServerId, SetError, State} +import org.apache.james.mailbox.MessageManager.{AppendCommand, FlagsUpdateMode} import org.apache.james.mailbox.exception.MailboxNotFoundException import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, DeleteResult, MailboxId, MessageId, MessageRange} import org.apache.james.mailbox.{MailboxManager, MailboxSession, MessageIdManager, MessageManager} +import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, DeleteResult, MailboxId, MessageId} +import org.apache.james.mailbox.{MailboxManager, MailboxSession, MessageIdManager} import org.apache.james.metrics.api.MetricFactory import play.api.libs.json.{JsError, JsObject, JsSuccess} import reactor.core.scala.publisher.{SFlux, SMono} @@ -56,28 +60,20 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, case class DestroyResults(results: Seq[DestroyResult]) { - def destroyed: Option[DestroyIds] = { - Option(results.flatMap({ - result => result match { + def destroyed: Option[DestroyIds] = + Option(results.flatMap{ case result: DestroySuccess => Some(result.messageId) case _ => None - } - }).map(EmailSet.asUnparsed)) + }.map(EmailSet.asUnparsed)) .filter(_.nonEmpty) .map(DestroyIds) - } - def notDestroyed: Option[Map[UnparsedMessageId, SetError]] = { - Option(results.flatMap({ - result => result match { - case failure: DestroyFailure => Some(failure) + def notDestroyed: Option[Map[UnparsedMessageId, SetError]] = + Option(results.flatMap{ + case failure: DestroyFailure => Some((failure.unparsedMessageId, failure.asMessageSetError)) case _ => None - } - }) - .map(failure => (failure.unparsedMessageId, failure.asMessageSetError)) - .toMap) + }.toMap) .filter(_.nonEmpty) - } } object DestroyResult { @@ -101,6 +97,31 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, } } + case class CreationResults(results: Seq[CreationResult]) { + def created: Option[Map[EmailCreationId, EmailCreationResponse]] = + Option(results.flatMap{ + case result: CreationSuccess => Some((result.clientId, result.response)) + case _ => None + }.toMap) + .filter(_.nonEmpty) + + def notCreated: Option[Map[EmailCreationId, SetError]] = { + Option(results.flatMap{ + case failure: CreationFailure => Some((failure.clientId, failure.asMessageSetError)) + case _ => None + } + .toMap) + .filter(_.nonEmpty) + } + } + trait CreationResult + case class CreationSuccess(clientId: EmailCreationId, response: EmailCreationResponse) extends CreationResult + case class CreationFailure(clientId: EmailCreationId, e: Throwable) extends CreationResult { + def asMessageSetError: SetError = e match { + case _ => SetError.serverFail(SetErrorDescription(e.getMessage)) + } + } + trait UpdateResult case class UpdateSuccess(messageId: MessageId) extends UpdateResult case class UpdateFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends UpdateResult { @@ -112,26 +133,19 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, } } case class UpdateResults(results: Seq[UpdateResult]) { - def updated: Option[Map[MessageId, Unit]] = { - Option(results.flatMap({ - result => result match { + def updated: Option[Map[MessageId, Unit]] = + Option(results.flatMap{ case result: UpdateSuccess => Some(result.messageId, ()) case _ => None - } - }).toMap).filter(_.nonEmpty) - } + }.toMap) + .filter(_.nonEmpty) - def notUpdated: Option[Map[UnparsedMessageId, SetError]] = { - Option(results.flatMap({ - result => result match { - case failure: UpdateFailure => Some(failure) + def notUpdated: Option[Map[UnparsedMessageId, SetError]] = + Option(results.flatMap{ + case failure: UpdateFailure => Some((failure.unparsedMessageId, failure.asMessageSetError)) case _ => None - } - }) - .map(failure => (failure.unparsedMessageId, failure.asMessageSetError)) - .toMap) + }.toMap) .filter(_.nonEmpty) - } } override val methodName: MethodName = MethodName("Email/set") @@ -140,19 +154,28 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: EmailSetRequest): SMono[InvocationWithContext] = { for { destroyResults <- destroy(request, mailboxSession) - updateResults <- update(request, mailboxSession).doOnError(e => e.printStackTrace()) + updateResults <- update(request, mailboxSession) + created <- create(request, mailboxSession) } yield InvocationWithContext( invocation = Invocation( methodName = invocation.invocation.methodName, arguments = Arguments(serializer.serialize(EmailSetResponse( accountId = request.accountId, newState = State.INSTANCE, + created = created.created, + notCreated = created.notCreated, updated = updateResults.updated, notUpdated = updateResults.notUpdated, destroyed = destroyResults.destroyed, notDestroyed = destroyResults.notDestroyed))), methodCallId = invocation.invocation.methodCallId), - processingContext = invocation.processingContext) + processingContext = created.created.getOrElse(Map()) + .foldLeft(invocation.processingContext)({ + case (processingContext, (clientId, response)) => + Id.validate(response.id.serialize) + .fold(_ => processingContext, + serverId => processingContext.recordCreatedId(ClientId(clientId), ServerId(serverId))) + })) } override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): SMono[EmailSetRequest] = asEmailSetRequest(invocation.arguments) @@ -188,6 +211,33 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, } } + private def create(request: EmailSetRequest, mailboxSession: MailboxSession): SMono[CreationResults] = + SFlux.fromIterable(request.create.getOrElse(Map())) + .concatMap { + case (clientId, json) => serializer.deserializeCreationRequest(json) + .fold(e => SMono.just[CreationResult](CreationFailure(clientId, new IllegalArgumentException(e.toString))), + creationRequest => create(clientId, creationRequest, mailboxSession)) + }.collectSeq() + .map(CreationResults) + + private def create(clientId: EmailCreationId, request: EmailCreationRequest, mailboxSession: MailboxSession): SMono[CreationResult] = { + if (request.mailboxIds.value.size != 1) { + SMono.just(CreationFailure(clientId, new IllegalArgumentException("mailboxIds need to have size 1"))) + } else { + SMono.fromCallable[CreationResult](() => { + val mailboxId: MailboxId = request.mailboxIds.value.headOption.get + val appendResult = mailboxManager.getMailbox(mailboxId, mailboxSession) + .appendMessage(AppendCommand.builder() + .recent() + .build(request.toMime4JMessage), + mailboxSession) + CreationSuccess(clientId, EmailCreationResponse(appendResult.getId.getMessageId)) + }) + .subscribeOn(Schedulers.elastic()) + .onErrorResume(e => SMono.just[CreationResult](CreationFailure(clientId, e))) + } + } + private def update(emailSetRequest: EmailSetRequest, mailboxSession: MailboxSession): SMono[UpdateResults] = { emailSetRequest.update .filter(_.nonEmpty) --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
