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 3653e5dc419f41aff0c63e0c64556a8b627a900c Author: LanKhuat <[email protected]> AuthorDate: Thu Oct 8 16:51:46 2020 +0700 JAMES-3410 Email/set destroy error handling --- .../rfc8621/contract/EmailSetMethodContract.scala | 478 +++++++++++++++++++-- .../rfc8621/memory/MemoryEmailSetMethodTest.java | 9 + .../james/jmap/json/EmailSetSerializer.scala | 19 +- .../org/apache/james/jmap/mail/EmailSet.scala | 30 +- .../apache/james/jmap/method/EmailSetMethod.scala | 120 ++++-- 5 files changed, 564 insertions(+), 92 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 17f5bc3..060d22a 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 @@ -19,8 +19,6 @@ package org.apache.james.jmap.rfc8621.contract import java.nio.charset.StandardCharsets -import java.time.format.DateTimeFormatter -import java.util.concurrent.TimeUnit import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT import io.restassured.RestAssured.{`given`, requestSpecification} @@ -28,27 +26,18 @@ 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.draft.JmapGuiceProbe import org.apache.james.jmap.http.UserCredential import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ANDRE, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} import org.apache.james.mailbox.MessageManager.AppendCommand -import org.apache.james.mailbox.model.{MailboxPath, MessageId} +import org.apache.james.mailbox.model.MailboxACL.Right +import org.apache.james.mailbox.model.{MailboxACL, MailboxId, MailboxPath, MessageId} import org.apache.james.mime4j.dom.Message -import org.apache.james.modules.MailboxProbeImpl +import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl} import org.apache.james.utils.DataProbeImpl -import org.awaitility.Awaitility -import org.awaitility.Duration.ONE_HUNDRED_MILLISECONDS import org.junit.jupiter.api.{BeforeEach, Test} trait EmailSetMethodContract { - private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS - private lazy val calmlyAwait = Awaitility.`with` - .pollInterval(slowPacedPollInterval) - .and.`with`.pollDelay(slowPacedPollInterval) - .await - private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(10, TimeUnit.SECONDS) - - private lazy val UTC_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX") - @BeforeEach def setUp(server: GuiceJamesServer): Unit = { server.getProbe(classOf[DataProbeImpl]) @@ -62,11 +51,13 @@ trait EmailSetMethodContract { .build } + def randomMessageId: MessageId + @Test def emailSetShouldDestroyEmail(server: GuiceJamesServer): Unit = { val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) mailboxProbe.createMailbox(MailboxPath.inbox(BOB)) - val messageId1: MessageId = mailboxProbe + val messageId: MessageId = mailboxProbe .appendMessage(BOB.asString, MailboxPath.inbox(BOB), AppendCommand.from( buildTestMessage)) @@ -80,44 +71,437 @@ trait EmailSetMethodContract { | "methodCalls": [ | ["Email/set", { | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "destroy": ["${messageId1.serialize}"] + | "destroy": ["${messageId.serialize}"] | }, "c1"], | ["Email/get", { | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "ids": ["${messageId1.serialize}"] + | "ids": ["${messageId.serialize}"] | }, "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).isEqualTo( - s"""{ - | "sessionState": "75128aab4b1b", - | "methodResponses": [ - | ["Email/set", { - | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "newState": "000001", - | "destroyed": ["${messageId1.serialize}"] - | }, "c1"], - | ["Email/get", { - | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "state": "000001", - | "list": [], - | "notFound": ["${messageId1.serialize}"] - | }, "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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "destroyed": ["${messageId.serialize}"] + | }, "c1"], + | ["Email/get", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "000001", + | "list": [], + | "notFound": ["${messageId.serialize}"] + | }, "c2"] + | ] + |}""".stripMargin) + } + + @Test + def emailSetDestroyShouldFailWhenInvalidMessageId(server: GuiceJamesServer): Unit = { + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": ["invalid"] + | }, "c1"]] + |}""".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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notDestroyed": { + | "invalid": { + | "type": "invalidArguments", + | "description": "invalid is not a messageId: For input string: \\"invalid\\"" + | } + | } + | }, "c1"]] + |}""".stripMargin) + } + + @Test + def emailSetDestroyShouldFailWhenMessageIdNotFound(server: GuiceJamesServer): Unit = { + val messageId = randomMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": ["${messageId.serialize}"] + | }, "c1"]] + |}""".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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notDestroyed": { + | "${messageId.serialize}": { + | "type": "notFound", + | "description": "Cannot find message with messageId: ${messageId.serialize}" + | } + | } + | }, "c1"]] + |}""".stripMargin) + } + + @Test + def emailSetDestroyShouldFailWhenMailDoesNotBelongToUser(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + mailboxProbe.createMailbox(MailboxPath.inbox(ANDRE)) + + val messageId: MessageId = mailboxProbe + .appendMessage(ANDRE.asString, MailboxPath.inbox(ANDRE), + AppendCommand.from( + buildTestMessage)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": ["${messageId.serialize}"] + | }, "c1"]] + |}""".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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notDestroyed": { + | "${messageId.serialize}": { + | "type": "notFound", + | "description": "Cannot find message with messageId: ${messageId.serialize}" + | } + | } + | }, "c1"] + | ] + |}""".stripMargin) + } + + @Test + def emailSetDestroyShouldFailWhenForbidden(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + + val andreMailbox: String = "andrecustom" + val path = MailboxPath.forUser(ANDRE, andreMailbox) + mailboxProbe.createMailbox(path) + + val messageId: MessageId = mailboxProbe + .appendMessage(ANDRE.asString, path, + AppendCommand.from( + buildTestMessage)) + .getMessageId + + server.getProbe(classOf[ACLProbeImpl]) + .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read)) + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": ["${messageId.serialize}"] + | }, "c1"]] + |}""".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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "notDestroyed": { + | "${messageId.serialize}": { + | "type": "notFound", + | "description": "Cannot find message with messageId: ${messageId.serialize}" + | } + | } + | }, "c1"] + | ] + |}""".stripMargin) + } + + @Test + def emailSetDestroyShouldDestroyEmailWhenShareeHasDeleteRight(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + + val andreMailbox: String = "andrecustom" + val path = MailboxPath.forUser(ANDRE, andreMailbox) + mailboxProbe.createMailbox(path) + + val messageId: MessageId = mailboxProbe + .appendMessage(ANDRE.asString, path, + AppendCommand.from( + buildTestMessage)) + .getMessageId + + server.getProbe(classOf[ACLProbeImpl]) + .replaceRights(path, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Read, Right.DeleteMessages)) + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": ["${messageId.serialize}"] + | }, "c1"], + | ["Email/get", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${messageId.serialize}"] + | }, "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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "destroyed": ["${messageId.serialize}"] + | }, "c1"], + | ["Email/get", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "000001", + | "list": [], + | "notFound": ["${messageId.serialize}"] + | }, "c2"] + | ] + |}""".stripMargin) + } + + @Test + def emailSetDestroyShouldDestroyEmailWhenMovedIntoAnotherMailbox(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + + val andreMailbox: String = "andrecustom" + val andrePath = MailboxPath.forUser(ANDRE, andreMailbox) + val bobPath = MailboxPath.inbox(BOB) + mailboxProbe.createMailbox(andrePath) + val mailboxId: MailboxId = mailboxProbe.createMailbox(bobPath) + + val messageId: MessageId = mailboxProbe + .appendMessage(ANDRE.asString, andrePath, + AppendCommand.from( + buildTestMessage)) + .getMessageId + + server.getProbe(classOf[ACLProbeImpl]) + .replaceRights(andrePath, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Insert)) + + server.getProbe(classOf[JmapGuiceProbe]) + .setInMailboxes(messageId, BOB, mailboxId) + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": ["${messageId.serialize}"] + | }, "c1"], + | ["Email/get", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": ["${messageId.serialize}"] + | }, "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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "destroyed": ["${messageId.serialize}"] + | }, "c1"], + | ["Email/get", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "state": "000001", + | "list": [], + | "notFound": ["${messageId.serialize}"] + | }, "c2"] + | ] + |}""".stripMargin) + } + + @Test + def emailSetDestroySuccessAndFailureCanBeMixed(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + mailboxProbe.createMailbox(MailboxPath.inbox(BOB)) + val path = MailboxPath.inbox(BOB) + + val messageId: MessageId = mailboxProbe + .appendMessage(BOB.asString, path, + AppendCommand.from( + buildTestMessage)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "destroy": [ + | "${messageId.serialize}", + | "invalid" + | ] + | }, "c1"]] + |}""".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).isEqualTo( + s"""{ + | "sessionState": "75128aab4b1b", + | "methodResponses": [ + | ["Email/set", { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "000001", + | "destroyed": ["${messageId.serialize}"], + | "notDestroyed": { + | "invalid": { + | "type": "invalidArguments", + | "description": "invalid is not a messageId: For input string: \\"invalid\\"" + | } + | } + | }, "c1"] + | ] + |}""".stripMargin) } private def buildTestMessage = { 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/MemoryEmailSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailSetMethodTest.java index ec7126a..cdf72f4 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailSetMethodTest.java @@ -21,10 +21,14 @@ package org.apache.james.jmap.rfc8621.memory; import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE; +import java.util.concurrent.ThreadLocalRandom; + import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.jmap.rfc8621.contract.EmailSetMethodContract; +import org.apache.james.mailbox.inmemory.InMemoryMessageId; +import org.apache.james.mailbox.model.MessageId; import org.apache.james.modules.TestJMAPServerModule; import org.junit.jupiter.api.extension.RegisterExtension; @@ -36,4 +40,9 @@ public class MemoryEmailSetMethodTest implements EmailSetMethodContract { .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE) .overrideWith(new TestJMAPServerModule())) .build(); + + @Override + public MessageId randomMessageId() { + return InMemoryMessageId.of(ThreadLocalRandom.current().nextInt(100000) + 100); + } } 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 d4ce747..594ceaa 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 @@ -20,21 +20,30 @@ package org.apache.james.jmap.json import javax.inject.Inject +import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId import org.apache.james.jmap.mail.{DestroyIds, EmailSetRequest, EmailSetResponse} +import org.apache.james.jmap.model.SetError import org.apache.james.mailbox.model.MessageId -import play.api.libs.json.{Format, JsError, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OFormat, Reads, Writes} +import play.api.libs.json.{Format, JsError, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes} + +import scala.util.Try class EmailSetSerializer @Inject() (messageIdFactory: MessageId.Factory) { private implicit val messageIdWrites: Writes[MessageId] = messageId => JsString(messageId.serialize) private implicit val messageIdReads: Reads[MessageId] = { - case JsString(serializedMessageId) => JsSuccess(messageIdFactory.fromString(serializedMessageId)) - case _ => JsError("Invalid messageId") + case JsString(serializedMessageId) => Try(JsSuccess(messageIdFactory.fromString(serializedMessageId))) + .fold(_ => JsError("Invalid messageId"), messageId => messageId) + case _ => JsError("Expecting messageId to be represented by a JsString") } - private implicit val destroyIdsFormat: Format[DestroyIds] = Json.valueFormat[DestroyIds] + private implicit val notDestroyedWrites: Writes[Map[UnparsedMessageId, SetError]] = mapWrites[UnparsedMessageId, SetError](_.value, setErrorWrites) + private implicit val destroyIdsReads: Reads[DestroyIds] = { + Json.valueFormat[DestroyIds] + } + private implicit val destroyIdsWrites: Writes[DestroyIds] = Json.valueWrites[DestroyIds] private implicit val emailRequestSetFormat: Format[EmailSetRequest] = Json.format[EmailSetRequest] - private implicit val emailResponseSetFormat: OFormat[EmailSetResponse] = Json.format[EmailSetResponse] + private implicit val emailResponseSetWrites: OWrites[EmailSetResponse] = Json.writes[EmailSetResponse] def deserialize(input: JsValue): JsResult[EmailSetRequest] = Json.fromJson[EmailSetRequest](input) 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 a47ecff..89813bb 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,16 +18,38 @@ ****************************************************************/ package org.apache.james.jmap.mail -import org.apache.james.jmap.model.AccountId +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.method.WithAccountId import org.apache.james.jmap.model.State.State +import org.apache.james.jmap.model.{AccountId, SetError} import org.apache.james.mailbox.model.MessageId -case class DestroyIds(value: Seq[MessageId]) +import scala.util.Try + +object EmailSet { + type UnparsedMessageIdConstraint = NonEmpty + type UnparsedMessageId = String Refined UnparsedMessageIdConstraint + + def asUnparsed(messageId: MessageId): UnparsedMessageId = refined.refineV[UnparsedMessageIdConstraint](messageId.serialize()) match { + case Left(e) => throw new IllegalArgumentException(e) + case scala.Right(value) => value + } + + def parse(messageIdFactory: MessageId.Factory)(unparsed: UnparsedMessageId): Try[MessageId] = + Try(messageIdFactory.fromString(unparsed.value)) +} + +case class DestroyIds(value: Seq[UnparsedMessageId]) + case class EmailSetRequest(accountId: AccountId, - destroy: Option[DestroyIds]) + destroy: Option[DestroyIds]) extends WithAccountId case class EmailSetResponse(accountId: AccountId, newState: State, - destroyed: Option[DestroyIds]) + destroyed: Option[DestroyIds], + notDestroyed: Option[Map[UnparsedMessageId, SetError]]) 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 9d086e4..304499a 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 @@ -22,64 +22,112 @@ import eu.timepit.refined.auto._ import javax.inject.Inject import org.apache.james.jmap.http.SessionSupplier import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer} -import org.apache.james.jmap.mail.{DestroyIds, EmailSetRequest, EmailSetResponse} +import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId +import org.apache.james.jmap.mail.{DestroyIds, EmailSet, EmailSetRequest, EmailSetResponse} 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.{Capabilities, Invocation, State} -import org.apache.james.mailbox.model.DeleteResult +import org.apache.james.jmap.model.SetError.SetErrorDescription +import org.apache.james.jmap.model.{Capabilities, Invocation, SetError, State} +import org.apache.james.mailbox.model.{DeleteResult, MessageId} import org.apache.james.mailbox.{MailboxSession, MessageIdManager} import org.apache.james.metrics.api.MetricFactory -import org.reactivestreams.Publisher import play.api.libs.json.{JsError, JsSuccess} -import reactor.core.scala.publisher.SMono +import reactor.core.scala.publisher.{SFlux, SMono} import reactor.core.scheduler.Schedulers import scala.jdk.CollectionConverters._ +case class MessageNotFoundExeception(messageId: MessageId) extends Exception + +case class DestroyResults(results: Seq[DestroyResult]) { + def destroyed: Option[DestroyIds] = { + Option(results.flatMap({ + result => result match { + case result: DestroySuccess => Some(result.messageId) + case _ => None + } + }).map(EmailSet.asUnparsed)) + .filter(_.nonEmpty) + .map(DestroyIds) + } + + def notDestroyed: Option[Map[UnparsedMessageId, SetError]] = { + Option(results.flatMap({ + result => result match { + case failure: DestroyFailure => Some(failure) + case _ => None + } + }) + .map(failure => (failure.unparsedMessageId, failure.asMessageSetError)) + .toMap) + .filter(_.nonEmpty) + } +} + +object DestroyResult { + def from(deleteResult: DeleteResult): DestroyResult = { + val notFound = deleteResult.getNotFound.asScala + + deleteResult.getDestroyed.asScala + .headOption + .map(DestroySuccess) + .getOrElse(DestroyFailure(EmailSet.asUnparsed(notFound.head), MessageNotFoundExeception(notFound.head))) + } +} + +trait DestroyResult +case class DestroySuccess(messageId: MessageId) extends DestroyResult +case class DestroyFailure(unparsedMessageId: UnparsedMessageId, e: Throwable) extends DestroyResult { + def asMessageSetError: SetError = e match { + case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(s"$unparsedMessageId is not a messageId: ${e.getMessage}")) + case e: MessageNotFoundExeception => SetError.notFound(SetErrorDescription(s"Cannot find message with messageId: ${e.messageId.serialize()}")) + case _ => SetError.serverFail(SetErrorDescription(e.getMessage)) + } +} + class EmailSetMethod @Inject()(serializer: EmailSetSerializer, messageIdManager: MessageIdManager, + messageIdFactory: MessageId.Factory, val metricFactory: MetricFactory, - val sessionSupplier: SessionSupplier) extends Method { + val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[EmailSetRequest] { override val methodName: MethodName = MethodName("Email/set") override val requiredCapabilities: Capabilities = Capabilities(CORE_CAPABILITY, MAIL_CAPABILITY) - override def process(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession): Publisher[InvocationWithContext] = { - asEmailSetRequest(invocation.invocation.arguments) - .flatMap(request => { - for { - destroyed <- destroy(request, mailboxSession) - } yield { - InvocationWithContext( - invocation = Invocation( - methodName = invocation.invocation.methodName, - arguments = Arguments(serializer.serialize(EmailSetResponse( - accountId = request.accountId, - newState = State.INSTANCE, - destroyed = destroyed))), - methodCallId = invocation.invocation.methodCallId), - processingContext = invocation.processingContext - ) - } - }) + override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: EmailSetRequest): SMono[InvocationWithContext] = { + for { + destroyResults <- destroy(request, mailboxSession) + } yield InvocationWithContext( + invocation = Invocation( + methodName = invocation.invocation.methodName, + arguments = Arguments(serializer.serialize(EmailSetResponse( + accountId = request.accountId, + newState = State.INSTANCE, + destroyed = destroyResults.destroyed, + notDestroyed = destroyResults.notDestroyed))), + methodCallId = invocation.invocation.methodCallId), + processingContext = invocation.processingContext) } - private def asEmailSetRequest(arguments: Arguments): SMono[EmailSetRequest] = { + override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): SMono[EmailSetRequest] = asEmailSetRequest(invocation.arguments) + + private def asEmailSetRequest(arguments: Arguments): SMono[EmailSetRequest] = serializer.deserialize(arguments.value) match { - case JsSuccess(request, _) => SMono.just(request) + case JsSuccess(emailSetRequest, _) => SMono.just(emailSetRequest) case errors: JsError => SMono.raiseError(new IllegalArgumentException(ResponseSerializer.serialize(errors).toString)) } - } - private def destroy(emailSetRequest: EmailSetRequest, mailboxSession: MailboxSession): SMono[Option[DestroyIds]] = - emailSetRequest.destroy - .map(destroyId => deleteMessages(destroyId, mailboxSession)) - .getOrElse(SMono.just(None)) + private def destroy(emailSetRequest: EmailSetRequest, mailboxSession: MailboxSession): SMono[DestroyResults] = + SFlux.fromIterable(emailSetRequest.destroy.getOrElse(DestroyIds(Seq())).value) + .flatMap(id => deleteMessage(id, mailboxSession)) + .collectSeq() + .map(DestroyResults) - private def deleteMessages(destroyIds: DestroyIds, mailboxSession: MailboxSession): SMono[Option[DestroyIds]] = { - SMono.fromCallable[DeleteResult](() => messageIdManager.delete(destroyIds.value.asJava, mailboxSession)) - .subscribeOn(Schedulers.elastic) - .map(deleteResult => Some(DestroyIds(deleteResult.getDestroyed.asScala.toSeq))) - } + private def deleteMessage(destroyId: UnparsedMessageId, mailboxSession: MailboxSession): SMono[DestroyResult] = + EmailSet.parse(messageIdFactory)(destroyId) + .fold(e => SMono.just(DestroyFailure(destroyId, e)), + parsedId => SMono.fromCallable(() => DestroyResult.from(messageIdManager.delete(parsedId, mailboxSession))) + .subscribeOn(Schedulers.elastic) + .onErrorRecover(e => DestroyFailure(EmailSet.asUnparsed(parsedId), e))) } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
