This is an automated email from the ASF dual-hosted git repository. rcordier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit f7aa673640acdee20d3f538fc7a379735cd1b241 Author: duc91 <[email protected]> AuthorDate: Tue Oct 13 17:26:03 2020 +0700 JAMES-3411 Email/set update keywords --- .../rfc8621/contract/EmailSetMethodContract.scala | 550 ++++++++++++++++++++- .../james/jmap/json/EmailSetSerializer.scala | 34 +- .../org/apache/james/jmap/mail/EmailSet.scala | 27 +- .../apache/james/jmap/method/EmailSetMethod.scala | 30 +- 4 files changed, 626 insertions(+), 15 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 758d6ac..3267358 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 @@ -18,25 +18,34 @@ ****************************************************************/ package org.apache.james.jmap.rfc8621.contract +import java.io.ByteArrayInputStream import java.nio.charset.StandardCharsets +import java.util.Date import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT import io.restassured.RestAssured.{`given`, requestSpecification} import io.restassured.http.ContentType.JSON +import javax.mail.Flags 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, MessageIdProbe} 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.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, ACCOUNT_ID, ANDRE, ANDRE_PASSWORD, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.mailbox.FlagsBuilder import org.apache.james.mailbox.MessageManager.AppendCommand import org.apache.james.mailbox.model.MailboxACL.Right -import org.apache.james.mailbox.model.{MailboxACL, MailboxId, MailboxPath, MessageId} +import org.apache.james.mailbox.model.{ComposedMessageId, MailboxACL, MailboxConstants, MailboxId, MailboxPath, MessageId} +import org.apache.james.mailbox.probe.MailboxProbe import org.apache.james.mime4j.dom.Message import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl} import org.apache.james.utils.DataProbeImpl import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.{BeforeEach, Test} +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +import scala.jdk.CollectionConverters._ trait EmailSetMethodContract { @BeforeEach @@ -55,6 +64,543 @@ trait EmailSetMethodContract { def randomMessageId: MessageId @Test + def shouldResetKeywords(server: GuiceJamesServer): Unit = { + val message: Message = Fixture.createTestMessage + + val flags: Flags = new Flags(Flags.Flag.ANSWERED) + + val bobPath = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), bobPath, AppendCommand.builder() + .withFlags(flags) + .build(message)) + .getMessageId + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["${messageId.serialize}"], + | "properties": ["keywords"] + | }, + | "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) + .inPath("methodResponses[1][1].list[0]") + .isEqualTo(String.format( + """{ + | "id":"%s", + | "keywords": { + | "music": true + | } + |} + """.stripMargin, messageId.serialize)) + } + + @Test + def shouldNotResetKeywordWhenFalseValue(server: GuiceJamesServer): Unit = { + val message: Message = Fixture.createTestMessage + + val flags: Flags = new Flags(Flags.Flag.ANSWERED) + + val bobPath = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), bobPath, AppendCommand.builder() + .withFlags(flags) + .build(message)) + .getMessageId + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "music": true, + | "movie": false + | } + | } + | } + | }, "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) + .inPath(s"methodResponses[0][1].notUpdated.${messageId.serialize}") + .isEqualTo( + """|{ + | "type":"invalidPatch", + | "description": "Message 1 update is invalid: List((,List(JsonValidationError(List(Value associated with keywords is invalid: List((,List(JsonValidationError(List(keyword value can only be true),ArraySeq()))))),ArraySeq()))))" + |}""".stripMargin) + } + + @Test + def shouldNotResetKeywordWhenInvalidKeyword(server: GuiceJamesServer): Unit = { + val message: Message = Fixture.createTestMessage + + val flags: Flags = new Flags(Flags.Flag.ANSWERED) + + val bobPath = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), bobPath, AppendCommand.builder() + .withFlags(flags) + .build(message)) + .getMessageId + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "mus*c": true + | } + | } + | } + | }, "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) + .inPath(s"methodResponses[0][1].notUpdated.${messageId.serialize}") + .isEqualTo( + """|{ + | "type":"invalidPatch", + | "description": "Message 1 update is invalid: List((,List(JsonValidationError(List(Value associated with keywords is invalid: List((,List(JsonValidationError(List(FlagName must not be null or empty, must have length form 1-255,must not contain characters with hex from '\\u0000' to '\\u00019' or {'(' ')' '{' ']' '%' '*' '\"' '\\'} ),ArraySeq()))))),ArraySeq()))))" + |}""".stripMargin) + } + + @ParameterizedTest + @ValueSource(strings = Array( + "$Recent", + "$Deleted" + )) + def shouldNotResetNonExposedKeyword(unexposedKeyword: String, server: GuiceJamesServer): Unit = { + val message: Message = Fixture.createTestMessage + + val flags: Flags = new Flags(Flags.Flag.ANSWERED) + + val bobPath = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), bobPath, AppendCommand.builder() + .withFlags(flags) + .build(message)) + .getMessageId + + val request = String.format( + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "music": true, + | "$unexposedKeyword": true + | } + | } + | } + | }, "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) + .inPath("methodResponses[0][1].notUpdated") + .isEqualTo( + s"""{ + | "${messageId.serialize}":{ + | "type":"invalidPatch", + | "description":"Message 1 update is invalid: Does not allow to update 'Deleted' or 'Recent' flag"} + | } + |}""" + .stripMargin) + } + + @Test + def shouldKeepUnexposedKeywordWhenResetKeywords(server: GuiceJamesServer): Unit = { + val mailboxProbe: MailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, BOB.asString(), "mailbox"); + + val bobPath = MailboxPath.forUser(BOB, "mailbox") + val message: ComposedMessageId = mailboxProbe.appendMessage(BOB.asString, bobPath, + new ByteArrayInputStream("Subject: test\r\n\r\ntestmail".getBytes(StandardCharsets.UTF_8)), + new Date, false, new Flags(Flags.Flag.DELETED)) + + val messageId: String = message.getMessageId.serialize + + val request = String.format(s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "$messageId":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, "c1"]] + |}""".stripMargin) + + `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + + val flags: List[Flags] = server.getProbe(classOf[MessageIdProbe]).getMessages(message.getMessageId, BOB).asScala.map(m => m.getFlags).toList + val expectedFlags: Flags = FlagsBuilder.builder.add("music").add(Flags.Flag.DELETED).build + + assertThat(flags.asJava) + .containsExactly(expectedFlags) + } + + @Test + def shouldResetKeywordsWhenNotDefault(server: GuiceJamesServer): Unit = { + val message: Message = Fixture.createTestMessage + + val flags: Flags = new Flags(Flags.Flag.ANSWERED) + + val bobPath = MailboxPath.inbox(BOB) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath) + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]).appendMessage(BOB.asString(), bobPath, AppendCommand.builder() + .withFlags(flags) + .build(message)) + .getMessageId + + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["${messageId.serialize}"], + | "properties": ["keywords"] + | }, + | "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) + .inPath("methodResponses[1][1].list[0]") + .isEqualTo(String.format( + """{ + | "id":"%s", + | "keywords": { + | "music": true + | } + |} + """.stripMargin, messageId.serialize)) + } + + @Test + def shouldNotResetKeywordWhenInvalidMessageId(server: GuiceJamesServer): Unit = { + val bobPath = MailboxPath.inbox(BOB) + 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", + | "update": { + | "invalid":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, "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) + .inPath("methodResponses[0][1].notUpdated") + .isEqualTo("""{ + | "invalid": { + | "type":"invalidPatch", + | "description":"Message invalid update is invalid: For input string: \"invalid\"" + | } + |}""".stripMargin) + } + + @Test + def shouldNotResetKeywordWhenMessageIdNonExisted(server: GuiceJamesServer): Unit = { + val invalidMessageId: MessageId = randomMessageId + + val bobPath = MailboxPath.inbox(BOB) + 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", + | "update": { + | "${invalidMessageId.serialize}":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, "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) + .inPath("methodResponses[0][1].notUpdated") + .isEqualTo(s"""{ + | "${invalidMessageId.serialize}": { + | "type":"notFound", + | "description":"Cannot find message with messageId: ${invalidMessageId.serialize}" + | } + |}""".stripMargin) + } + + @Test + def shouldNotUpdateInDelegatedMailboxesWhenReadOnly(server: GuiceJamesServer): Unit = { + val andreMailbox: String = "andrecustom" + val andrePath = MailboxPath.forUser(ANDRE, andreMailbox) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath) + val message: Message = Message.Builder + .of + .setSender(BOB.asString()) + .setFrom(ANDRE.asString()) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(ANDRE.asString, andrePath, AppendCommand.from(message)) + .getMessageId + server.getProbe(classOf[ACLProbeImpl]) + .replaceRights(andrePath, BOB.asString, MailboxACL.Rfc4314Rights.of(Set(Right.Read, Right.Lookup).asJava)) + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", + | { + | "accountId": "$ACCOUNT_ID", + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, + | "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) + .inPath("methodResponses[0][1].notUpdated") + .isEqualTo( + s"""{ + | "${messageId.serialize}":{ + | "type": "notFound", + | "description": "Mailbox not found" + | } + |}""".stripMargin) + } + + @Test + def shouldResetFlagsInDelegatedMailboxesWhenHadAtLeastWriteRight(server: GuiceJamesServer): Unit = { + val andreMailbox: String = "andrecustom" + val andrePath = MailboxPath.forUser(ANDRE, andreMailbox) + server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath) + val message: Message = Message.Builder + .of + .setSender(BOB.asString()) + .setFrom(ANDRE.asString()) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build + val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl]) + .appendMessage(ANDRE.asString, andrePath, AppendCommand.from(message)) + .getMessageId + server.getProbe(classOf[ACLProbeImpl]) + .replaceRights(andrePath, BOB.asString, MailboxACL.Rfc4314Rights.of(Set(Right.Write, Right.Read).asJava)) + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail"], + | "methodCalls": [ + | ["Email/set", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["${messageId.serialize}"], + | "update": { + | "${messageId.serialize}":{ + | "keywords": { + | "music": true + | } + | } + | } + | }, + | "c1"], + | ["Email/get", + | { + | "accountId": "$ACCOUNT_ID", + | "ids": ["${messageId.serialize}"], + | "properties": ["keywords"] + | }, + | "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) + .inPath("methodResponses[1][1].list[0]") + .isEqualTo(String.format( + """{ + | "id":"%s", + | "keywords": { + | "music":true + | } + |} + """.stripMargin, messageId.serialize)) + } + + @Test def emailSetShouldDestroyEmail(server: GuiceJamesServer): Unit = { val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) mailboxProbe.createMailbox(MailboxPath.inbox(BOB)) 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 556f194..28e79c2 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 @@ -23,7 +23,7 @@ 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.model.SetError +import org.apache.james.jmap.model.{Keyword, Keywords, SetError} import org.apache.james.mailbox.model.{MailboxId, MessageId} import play.api.libs.json.{JsBoolean, JsError, JsNull, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes} @@ -46,6 +46,12 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI }.headOption .map(_.ids) + val keywordsReset: Option[Keywords] = entries.flatMap { + case update: KeywordsReset => Some(update) + case _ => None + }.headOption + .map(_.keywords) + val mailboxesToAdd: Option[MailboxIds] = Some(entries .flatMap { case update: MailboxAddition => Some(update) @@ -62,7 +68,8 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI .filter(_.nonEmpty) .map(MailboxIds) - JsSuccess(EmailSetUpdate(mailboxIds = mailboxReset, + JsSuccess(EmailSetUpdate(keywords = keywordsReset, + mailboxIds = mailboxReset, mailboxIdsToAdd = mailboxesToAdd, mailboxIdsToRemove = mailboxesToRemove)) }) @@ -75,6 +82,10 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI .fold( e => InvalidPatchEntryValue(property, e.toString()), MailboxReset) + case "keywords" => keywordsReads.reads(value) + .fold( + e => InvalidPatchEntryValue(property, e.toString()), + KeywordsReset) case name if name.startsWith(mailboxIdPrefix) => Try(mailboxIdFactory.fromString(name.substring(mailboxIdPrefix.length))) .fold(e => InvalidPatchEntryNameWithDetails(property, e.getMessage), id => value match { @@ -108,6 +119,8 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI private case class MailboxReset(ids: MailboxIds) extends EntryValidation + private case class KeywordsReset(keywords: Keywords) extends EntryValidation + } private implicit val messageIdWrites: Writes[MessageId] = messageId => JsString(messageId.serialize) @@ -140,6 +153,23 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI case _ => JsError("Expecting a JsObject as an update entry") }) + private implicit val keywordReads: Reads[Keyword] = { + case jsString: JsString => Keyword.parse(jsString.value) + .fold(JsError(_), + JsSuccess(_)) + case _ => JsError("Expecting a string as a keyword") + } + + private implicit val keywordsMapReads: Reads[Map[Keyword, Boolean]] = + readMapEntry[Keyword, Boolean](s => Keyword.parse(s), + { + case JsBoolean(true) => JsSuccess(true) + case JsBoolean(false) => JsError("keyword value can only be true") + case _ => JsError("Expecting keyword value to be a boolean") + }) + private implicit val keywordsReads: Reads[Keywords] = jsValue => keywordsMapReads.reads(jsValue).map( + keywordsMap => Keywords(keywordsMap.keys.toSet)) + private implicit val unitWrites: Writes[Unit] = _ => JsNull private implicit val updatedWrites: Writes[Map[MessageId, Unit]] = mapWrites[MessageId, Unit](_.serialize, unitWrites) private implicit val notDestroyedWrites: Writes[Map[UnparsedMessageId, SetError]] = mapWrites[UnparsedMessageId, SetError](_.value, setErrorWrites) 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 ab692e5..c2ea230 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 @@ -23,12 +23,13 @@ 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.KeywordsFactory.STRICT_KEYWORDS_FACTORY import org.apache.james.jmap.model.State.State -import org.apache.james.jmap.model.{AccountId, SetError} +import org.apache.james.jmap.model.{AccountId, Keywords, SetError} import org.apache.james.mailbox.model.MessageId import play.api.libs.json.JsObject -import scala.util.Try +import scala.util.{Failure, Right, Success, Try} object EmailSet { type UnparsedMessageIdConstraint = NonEmpty @@ -56,7 +57,8 @@ case class EmailSetResponse(accountId: AccountId, destroyed: Option[DestroyIds], notDestroyed: Option[Map[UnparsedMessageId, SetError]]) -case class EmailSetUpdate(mailboxIds: Option[MailboxIds], +case class EmailSetUpdate(keywords: Option[Keywords], + mailboxIds: Option[MailboxIds], mailboxIdsToAdd: Option[MailboxIds], mailboxIdsToRemove: Option[MailboxIds]) { def validate: Either[IllegalArgumentException, ValidatedEmailSetUpdate] = if (mailboxIds.isDefined && (mailboxIdsToAdd.isDefined || mailboxIdsToRemove.isDefined)) { @@ -75,15 +77,26 @@ case class EmailSetUpdate(mailboxIds: Option[MailboxIds], val mailboxIdsTransformation: Function[MailboxIds, MailboxIds] = mailboxIdsAddition .compose(mailboxIdsRemoval) .compose(mailboxIdsReset) - scala.Right(ValidatedEmailSetUpdate(mailboxIdsTransformation)) + Right(mailboxIdsTransformation) + .flatMap(mailboxIdsTransformation => validateKeywords + .map(validatedKeywords => ValidatedEmailSetUpdate(validatedKeywords, mailboxIdsTransformation))) + } + + private def validateKeywords: Either[IllegalArgumentException, Option[Keywords]] = { + keywords.map(_.getKeywords) + .map(STRICT_KEYWORDS_FACTORY.fromSet) + .map { + case Success(validatedKeywords: Keywords) => Right(Some(validatedKeywords)) + case Failure(throwable: IllegalArgumentException) => Left(throwable) + } + .getOrElse(Right(None)) } } -case class ValidatedEmailSetUpdate private (mailboxIdsTransformation: Function[MailboxIds, MailboxIds]) +case class ValidatedEmailSetUpdate private (keywords: Option[Keywords], + mailboxIdsTransformation: Function[MailboxIds, MailboxIds]) class EmailUpdateValidationException() extends IllegalArgumentException case class InvalidEmailPropertyException(property: String, cause: String) extends EmailUpdateValidationException case class InvalidEmailUpdateException(property: String, cause: String) extends EmailUpdateValidationException - - 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 c7022f8..74d72b3 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 @@ -18,8 +18,10 @@ ****************************************************************/ package org.apache.james.jmap.method +import com.google.common.collect.ImmutableList import eu.timepit.refined.auto._ import javax.inject.Inject +import javax.mail.Flags import org.apache.james.jmap.http.SessionSupplier import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer} import org.apache.james.jmap.mail.EmailSet.UnparsedMessageId @@ -29,6 +31,7 @@ import org.apache.james.jmap.model.DefaultCapabilities.{CORE_CAPABILITY, MAIL_CA import org.apache.james.jmap.model.Invocation.{Arguments, MethodName} 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.mailbox.exception.MailboxNotFoundException import org.apache.james.mailbox.model.{ComposedMessageIdWithMetaData, DeleteResult, MessageId} import org.apache.james.mailbox.{MailboxSession, MessageIdManager} @@ -195,7 +198,7 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, .collectMultimap(metaData => metaData.getComposedMessageId.getMessageId) .flatMap(metaData => { SFlux.fromIterable(validUpdates) - .flatMap[UpdateResult]({ + .concatMap[UpdateResult]({ case (messageId, updatePatch) => doUpdate(messageId, updatePatch, metaData.get(messageId).toList.flatten, session) }) @@ -208,6 +211,11 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, private def doUpdate(messageId: MessageId, update: EmailSetUpdate, storedMetaData: List[ComposedMessageIdWithMetaData], session: MailboxSession): SMono[UpdateResult] = { val mailboxIds: MailboxIds = MailboxIds(storedMetaData.map(metaData => metaData.getComposedMessageId.getMailboxId)) + val originFlags: Flags = storedMetaData + .foldLeft[Flags](new Flags())((flags: Flags, m: ComposedMessageIdWithMetaData) => { + flags.add(m.getFlags) + flags + }) if (mailboxIds.value.isEmpty) { SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), MessageNotFoundExeception(messageId))) @@ -215,9 +223,14 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, update.validate .fold( e => SMono.just(UpdateFailure(EmailSet.asUnparsed(messageId), e)), - validatedUpdate => updateMailboxIds(messageId, validatedUpdate, mailboxIds, session) - .onErrorResume(e => SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e))) - .switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId)))) + validatedUpdate => + resetFlags(messageId, validatedUpdate, mailboxIds, originFlags, session) + .flatMap { + case failure: UpdateFailure => SMono.just[UpdateResult](failure) + case _: UpdateSuccess => updateMailboxIds(messageId, validatedUpdate, mailboxIds, session) + } + .onErrorResume(e => SMono.just[UpdateResult](UpdateFailure(EmailSet.asUnparsed(messageId), e))) + .switchIfEmpty(SMono.just[UpdateResult](UpdateSuccess(messageId)))) } } @@ -234,6 +247,15 @@ class EmailSetMethod @Inject()(serializer: EmailSetSerializer, } } + private def resetFlags(messageId: MessageId, update: ValidatedEmailSetUpdate, mailboxIds: MailboxIds, originalFlags: Flags, session: MailboxSession): SMono[UpdateResult] = + update.keywords + .map(keywords => keywords.asFlagsWithRecentAndDeletedFrom(originalFlags)) + .map(flags => SMono.fromCallable(() => + messageIdManager.setFlags(flags, FlagsUpdateMode.REPLACE, messageId, ImmutableList.copyOf(mailboxIds.value.asJavaCollection), session)) + .subscribeOn(Schedulers.elastic()) + .`then`(SMono.just[UpdateResult](UpdateSuccess(messageId)))) + .getOrElse(SMono.just[UpdateResult](UpdateSuccess(messageId))) + private def deleteMessage(destroyId: UnparsedMessageId, mailboxSession: MailboxSession): SMono[DestroyResult] = EmailSet.parse(messageIdFactory)(destroyId) .fold(e => SMono.just(DestroyFailure(destroyId, e)), --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
