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 22a6e85923b4b3137980bf9ac845a66be6d43ff6 Author: Benoit Tellier <[email protected]> AuthorDate: Mon Sep 28 14:51:00 2020 +0700 JAMES-3377 Email/query: search headers --- .../contract/EmailQueryMethodContract.scala | 422 +++++++++++++++++++-- .../doc/specs/spec/mail/message.mdown | 1 - .../james/jmap/json/EmailQuerySerializer.scala | 23 +- .../org/apache/james/jmap/mail/EmailQuery.scala | 6 +- .../james/jmap/utils/search/MailboxFilter.scala | 5 +- 5 files changed, 408 insertions(+), 49 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/EmailQueryMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala index e39f014..e954bf5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala @@ -47,6 +47,7 @@ import org.apache.james.mailbox.model.{MailboxACL, MailboxPath, MessageId} import org.apache.james.mime4j.dom.Message import org.apache.james.mime4j.field.address.DefaultAddressParser import org.apache.james.mime4j.message.DefaultMessageWriter +import org.apache.james.mime4j.stream.RawField import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl} import org.apache.james.utils.DataProbeImpl import org.awaitility.Awaitility @@ -592,6 +593,387 @@ trait EmailQueryMethodContract { } @Test + def headerExistsShouldBeCaseInsentive(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + val bobInboxId = mailboxProbe.createMailbox(inbox(BOB)) + val messageId1: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "value")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId2: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail", + | "urn:apache:james:params:jmap:mail:shares"], + | "methodCalls": [[ + | "Email/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "filter": {"header": ["X-SpEcIfIc"]} + | }, + | "c1"]] + |}""".stripMargin + + awaitAtMostTenSeconds.untilAsserted { () => + 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/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "queryState": "${generateQueryState(messageId1)}", + | "canCalculateChanges": false, + | "position": 0, + | "limit": 256, + | "ids": ["${messageId1.serialize()}"] + | }, + | "c1" + | ]] + |}""".stripMargin) + } + } + + @Test + def headerShouldAllowToMatchMailWithSpecificHeaderSet(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + val bobInboxId = mailboxProbe.createMailbox(inbox(BOB)) + val messageId1: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "value")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId2: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail", + | "urn:apache:james:params:jmap:mail:shares"], + | "methodCalls": [[ + | "Email/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "filter": {"header": ["X-Specific"]} + | }, + | "c1"]] + |}""".stripMargin + + awaitAtMostTenSeconds.untilAsserted { () => + 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/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "queryState": "${generateQueryState(messageId1)}", + | "canCalculateChanges": false, + | "position": 0, + | "limit": 256, + | "ids": ["${messageId1.serialize()}"] + | }, + | "c1" + | ]] + |}""".stripMargin) + } + } + + @Test + def headerShouldAllowToMatchMailWithSpecificValueHeaderSet(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + val bobInboxId = mailboxProbe.createMailbox(inbox(BOB)) + val messageId1: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "value")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId2: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId3: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "other")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail", + | "urn:apache:james:params:jmap:mail:shares"], + | "methodCalls": [[ + | "Email/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "filter": {"header": ["X-Specific", "value"]} + | }, + | "c1"]] + |}""".stripMargin + + awaitAtMostTenSeconds.untilAsserted { () => + 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/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "queryState": "${generateQueryState(messageId1)}", + | "canCalculateChanges": false, + | "position": 0, + | "limit": 256, + | "ids": ["${messageId1.serialize()}"] + | }, + | "c1" + | ]] + |}""".stripMargin) + } + } + + @Test + def headerContainsShouldBeCaseInsentive(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + val bobInboxId = mailboxProbe.createMailbox(inbox(BOB)) + val messageId1: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "VaLuE")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId2: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId3: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "other")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail", + | "urn:apache:james:params:jmap:mail:shares"], + | "methodCalls": [[ + | "Email/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "filter": {"header": ["X-Specific", "value"]} + | }, + | "c1"]] + |}""".stripMargin + + awaitAtMostTenSeconds.untilAsserted { () => + 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/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "queryState": "${generateQueryState(messageId1)}", + | "canCalculateChanges": false, + | "position": 0, + | "limit": 256, + | "ids": ["${messageId1.serialize()}"] + | }, + | "c1" + | ]] + |}""".stripMargin) + } + } + + @Test + def headerShouldRejectWhenMoreThanTwoItems(server: GuiceJamesServer): Unit = { + val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) + val bobInboxId = mailboxProbe.createMailbox(inbox(BOB)) + val messageId1: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "value")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId2: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + val messageId3: MessageId = mailboxProbe + .appendMessage(BOB.asString, inbox(BOB), + AppendCommand.from( + Message.Builder + .of + .addField(new RawField("X-Specific", "other")) + .setSubject("test") + .setBody("testmail", StandardCharsets.UTF_8) + .build)) + .getMessageId + + val request = + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:mail", + | "urn:apache:james:params:jmap:mail:shares"], + | "methodCalls": [[ + | "Email/query", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "filter": {"header": ["X-Specific", "value", "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": [ + | [ + | "error", + | { + | "type": "invalidArguments", + | "description": "{\\"errors\\":[{\\"path\\":\\"obj.filter.header\\",\\"messages\\":[\\"header filter needs to be an array of one or two strings\\"]}]}" + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + + @Test def hasAttachmentShouldKeepMessageWithoutAttachmentWhenFalse(server: GuiceJamesServer): Unit = { val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl]) mailboxProbe.createMailbox(MailboxPath.inbox(BOB)) @@ -1645,46 +2027,6 @@ trait EmailQueryMethodContract { """) } - @Test - def listMailsShouldReturnUnsupportedFilterWhenHeaderFilter(): Unit = { - val request = - s"""{ - | "using": [ - | "urn:ietf:params:jmap:core", - | "urn:ietf:params:jmap:mail"], - | "methodCalls": [[ - | "Email/query", - | { - | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "filter" : { - | "header": ["header1", "header2"] - | } - | }, - | "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]") - .isEqualTo(s""" - { - "type": "unsupportedFilter", - "description": "The filter header is syntactically valid, but the server cannot process it. If the filter was the result of a user’s search input, the client SHOULD suggest that the user simplify their search." - } - """) - } - @ParameterizedTest @ValueSource(strings = Array( "true", diff --git a/server/protocols/jmap-rfc-8621/doc/specs/spec/mail/message.mdown b/server/protocols/jmap-rfc-8621/doc/specs/spec/mail/message.mdown index 175ccfd..28a62aa 100644 --- a/server/protocols/jmap-rfc-8621/doc/specs/spec/mail/message.mdown +++ b/server/protocols/jmap-rfc-8621/doc/specs/spec/mail/message.mdown @@ -741,7 +741,6 @@ These properties are not supported yet for filtering: - noneInThreadHaveKeyword - text - body -- header </aside> The exact semantics for matching `String` fields is **deliberately not defined** to allow for flexibility in indexing implementation, subject to the following: diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailQuerySerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailQuerySerializer.scala index 84b90af..f137959 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailQuerySerializer.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/EmailQuerySerializer.scala @@ -20,9 +20,8 @@ package org.apache.james.jmap.json import javax.inject.Inject -import org.apache.james.jmap.mail.{AllInThreadHaveKeywordSortProperty, Anchor, AnchorOffset, Bcc, Body, Cc, CollapseThreads, From, FromSortProperty, HasKeywordSortProperty, Header, SizeSortProperty, SomeInThreadHaveKeywordSortProperty, Subject, SubjectSortProperty, Text, To, ToSortProperty, HasAttachment, Collation, Comparator, EmailQueryRequest, EmailQueryResponse, FilterCondition, IsAscending, ReceivedAtSortProperty, SentAtSortProperty, SortProperty} -import org.apache.james.jmap.model.{CanCalculateChanges, LimitUnparsed, PositionUnparsed, QueryState} -import org.apache.james.jmap.model.{AccountId, Keyword} +import org.apache.james.jmap.mail.{AllInThreadHaveKeywordSortProperty, Anchor, AnchorOffset, Bcc, Body, Cc, CollapseThreads, Collation, Comparator, EmailQueryRequest, EmailQueryResponse, FilterCondition, From, FromSortProperty, HasAttachment, HasKeywordSortProperty, Header, HeaderContains, HeaderExist, IsAscending, ReceivedAtSortProperty, SentAtSortProperty, SizeSortProperty, SomeInThreadHaveKeywordSortProperty, SortProperty, Subject, SubjectSortProperty, Text, To, ToSortProperty} +import org.apache.james.jmap.model.{AccountId, CanCalculateChanges, Keyword, LimitUnparsed, PositionUnparsed, QueryState} import org.apache.james.mailbox.model.{MailboxId, MessageId} import play.api.libs.json._ @@ -56,7 +55,23 @@ class EmailQuerySerializer @Inject()(mailboxIdFactory: MailboxId.Factory) { private implicit val ccReads: Reads[Cc] = Json.valueReads[Cc] private implicit val bccReads: Reads[Bcc] = Json.valueReads[Bcc] private implicit val subjectReads: Reads[Subject] = Json.valueReads[Subject] - private implicit val headerReads: Reads[Header] = Json.valueReads[Header] + private implicit val headerReads: Reads[Header] = { + case array: JsArray if array.value.length == 1 => + extractString(array.value.head) + .fold[JsResult[Header]](e => e, name => JsSuccess(HeaderExist(name))) + case array: JsArray if array.value.length == 2 => + extractString(array.value.head) + .flatMap(name => extractString(array.value.last) + .map(value => (name, value))) + .fold(e => e, { + case (name, value) => JsSuccess(HeaderContains(name, value)) + }) + case _ => JsError("header filter needs to be an array of one or two strings") + } + private def extractString(jsValue: JsValue): Either[JsResult[Header], String] = jsValue match { + case JsString(value) => Right(value) + case _ => Left(JsError("header filter needs to be an array of one or two strings")) + } private implicit val bodyReads: Reads[Body] = Json.valueReads[Body] private implicit val filterConditionReads: Reads[FilterCondition] = Json.reads[FilterCondition] private implicit val limitUnparsedReads: Reads[LimitUnparsed] = Json.valueReads[LimitUnparsed] diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailQuery.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailQuery.scala index 8ca075a..bb27528 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailQuery.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailQuery.scala @@ -38,7 +38,9 @@ case class To(value: String) extends AnyVal case class Cc(value: String) extends AnyVal case class Bcc(value: String) extends AnyVal case class Body(value: String) extends AnyVal -case class Header(value: String) extends AnyVal +sealed trait Header +case class HeaderExist(name: String) extends Header +case class HeaderContains(name: String, value: String) extends Header case class FilterCondition(inMailbox: Option[MailboxId], inMailboxOtherThan: Option[Seq[MailboxId]], @@ -58,7 +60,7 @@ case class FilterCondition(inMailbox: Option[MailboxId], cc: Option[Cc], bcc: Option[Bcc], subject: Option[Subject], - header: Option[Set[Header]], + header: Option[Header], body: Option[Body]) case class EmailQueryRequest(accountId: AccountId, diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/utils/search/MailboxFilter.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/utils/search/MailboxFilter.scala index 94d2344..30a1022 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/utils/search/MailboxFilter.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/utils/search/MailboxFilter.scala @@ -21,7 +21,7 @@ package org.apache.james.jmap.utils.search import java.util.Date import cats.implicits._ -import org.apache.james.jmap.mail.{EmailQueryRequest, UnsupportedFilterException} +import org.apache.james.jmap.mail.{EmailQueryRequest, HeaderContains, HeaderExist, UnsupportedFilterException} import org.apache.james.jmap.model.CapabilityIdentifier import org.apache.james.jmap.model.CapabilityIdentifier.CapabilityIdentifier import org.apache.james.mailbox.MailboxSession @@ -220,7 +220,8 @@ object MailboxFilter { case object Header extends QueryFilter { override def toQuery(builder: SearchQuery.Builder, request: EmailQueryRequest): Either[UnsupportedFilterException, SearchQuery.Builder] = request.filter.flatMap(_.header) match { - case Some(_) => Left(UnsupportedFilterException("header")) + case Some(HeaderExist(name)) => Right(builder.andCriteria(SearchQuery.headerExists(name))) + case Some(HeaderContains(name, value)) => Right(builder.andCriteria(SearchQuery.headerContains(name, value))) case None => Right(builder) } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
