You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2024/01/31 15:37:58 UTC
(james-project) branch master updated: JAMES-3976 Email/import should accept empty keywords (#1962)
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
The following commit(s) were added to refs/heads/master by this push:
new c5863e50d2 JAMES-3976 Email/import should accept empty keywords (#1962)
c5863e50d2 is described below
commit c5863e50d2a239f56a577f8101c5e92c18315ce5
Author: Benoit TELLIER <bt...@linagora.com>
AuthorDate: Wed Jan 31 16:37:51 2024 +0100
JAMES-3976 Email/import should accept empty keywords (#1962)
---
.../rfc8621/contract/EmailImportContract.scala | 365 +++++++++++++++++++++
.../rfc8621/contract/EmailSetMethodContract.scala | 51 +++
.../org/apache/james/jmap/mail/EmailImport.scala | 9 +-
.../org/apache/james/jmap/mail/Keywords.scala | 4 +-
.../jmap/method/EmailSetCreatePerformer.scala | 5 +-
5 files changed, 427 insertions(+), 7 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/EmailImportContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailImportContract.scala
index a10cdb4132..539cb0dc62 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailImportContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailImportContract.scala
@@ -268,6 +268,371 @@ trait EmailImportContract {
| ]""".stripMargin)
}
+ @Test
+ def importShouldSucceedWhenEmptyKeyword(server: GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val receivedAt = ZonedDateTime.now().minusDays(1)
+
+ val uploadResponse: String = `given`
+ .basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(ClassLoader.getSystemResourceAsStream("eml/alternative.eml"))
+ .when
+ .post(s"/upload/$ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .body
+ .asString
+
+ val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value
+
+ val receivedAtString = UTCDate(receivedAt).asUTC.format(UTC_DATE_FORMAT)
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [
+ | ["Email/import",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "emails": {
+ | "C42": {
+ | "blobId": "$blobId",
+ | "mailboxIds": {
+ | "${mailboxId.serialize()}": true
+ | },
+ | "keywords": {},
+ | "receivedAt": "$receivedAtString"
+ | }
+ | }
+ | },
+ | "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#C42"],
+ | "properties": ["keywords", "mailboxIds", "receivedAt", "subject", "size", "bodyValues", "htmlBody"],
+ | "fetchHTMLBodyValues": true
+ | },
+ | "c2"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ val responseAsJson = Json.parse(response)
+ .\("methodResponses")
+ .\(0).\(1)
+ .\("created")
+ .\("C42")
+
+ val messageId = responseAsJson
+ .\("id")
+ .get.asInstanceOf[JsString].value
+
+ assertThatJson(response)
+ .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+ .inPath("methodResponses")
+ .isEqualTo(
+ s""" [
+ | ["Email/import",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "created": {
+ | "C42": {
+ | "id": "$messageId",
+ | "blobId": "$messageId",
+ | "threadId": "$messageId",
+ | "size": 836
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "notFound": [],
+ | "list": [
+ | {
+ | "id": "$messageId",
+ | "htmlBody": [
+ | {
+ | "charset": "utf-8",
+ | "size": 39,
+ | "partId": "2",
+ | "blobId": "${messageId}_2",
+ | "type": "text/html"
+ | }
+ | ],
+ | "size": 836,
+ | "keywords": {},
+ | "subject": "MultiAttachment",
+ | "mailboxIds": {
+ | "${mailboxId.serialize()}": true
+ | },
+ | "receivedAt": "$receivedAtString",
+ | "bodyValues": {
+ | "2": {
+ | "value": "<p>Send<br/>concerted from html</p>\\r\\n\\r\\n",
+ | "isEncodingProblem": false,
+ | "isTruncated": false
+ | }
+ | }
+ | }
+ | ]
+ | }, "c2"]
+ | ]""".stripMargin)
+ }
+
+ @Test
+ def importShouldSucceedWhenNoKeyword(server: GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val receivedAt = ZonedDateTime.now().minusDays(1)
+
+ val uploadResponse: String = `given`
+ .basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(ClassLoader.getSystemResourceAsStream("eml/alternative.eml"))
+ .when
+ .post(s"/upload/$ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .body
+ .asString
+
+ val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value
+
+ val receivedAtString = UTCDate(receivedAt).asUTC.format(UTC_DATE_FORMAT)
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [
+ | ["Email/import",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "emails": {
+ | "C42": {
+ | "blobId": "$blobId",
+ | "mailboxIds": {
+ | "${mailboxId.serialize()}": true
+ | },
+ | "receivedAt": "$receivedAtString"
+ | }
+ | }
+ | },
+ | "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#C42"],
+ | "properties": ["keywords", "mailboxIds", "receivedAt", "subject", "size", "bodyValues", "htmlBody"],
+ | "fetchHTMLBodyValues": true
+ | },
+ | "c2"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ val responseAsJson = Json.parse(response)
+ .\("methodResponses")
+ .\(0).\(1)
+ .\("created")
+ .\("C42")
+
+ val messageId = responseAsJson
+ .\("id")
+ .get.asInstanceOf[JsString].value
+
+ assertThatJson(response)
+ .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+ .inPath("methodResponses")
+ .isEqualTo(
+ s""" [
+ | ["Email/import",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "created": {
+ | "C42": {
+ | "id": "$messageId",
+ | "blobId": "$messageId",
+ | "threadId": "$messageId",
+ | "size": 836
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "notFound": [],
+ | "list": [
+ | {
+ | "id": "$messageId",
+ | "htmlBody": [
+ | {
+ | "charset": "utf-8",
+ | "size": 39,
+ | "partId": "2",
+ | "blobId": "${messageId}_2",
+ | "type": "text/html"
+ | }
+ | ],
+ | "size": 836,
+ | "keywords": {},
+ | "subject": "MultiAttachment",
+ | "mailboxIds": {
+ | "${mailboxId.serialize()}": true
+ | },
+ | "receivedAt": "$receivedAtString",
+ | "bodyValues": {
+ | "2": {
+ | "value": "<p>Send<br/>concerted from html</p>\\r\\n\\r\\n",
+ | "isEncodingProblem": false,
+ | "isTruncated": false
+ | }
+ | }
+ | }
+ | ]
+ | }, "c2"]
+ | ]""".stripMargin)
+ }
+
+ @Test
+ def importShouldSucceedWhenNoReceivedAt(server: GuiceJamesServer): Unit = {
+ val bobPath = MailboxPath.inbox(BOB)
+ val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+ val receivedAt = ZonedDateTime.now().minusDays(1)
+
+ val uploadResponse: String = `given`
+ .basePath("")
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(ClassLoader.getSystemResourceAsStream("eml/alternative.eml"))
+ .when
+ .post(s"/upload/$ACCOUNT_ID")
+ .`then`
+ .statusCode(SC_CREATED)
+ .extract
+ .body
+ .asString
+
+ val blobId: String = Json.parse(uploadResponse).\("blobId").get.asInstanceOf[JsString].value
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(s"""{
+ | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [
+ | ["Email/import",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "emails": {
+ | "C42": {
+ | "blobId": "$blobId",
+ | "mailboxIds": {
+ | "${mailboxId.serialize()}": true
+ | }
+ | }
+ | }
+ | },
+ | "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#C42"],
+ | "properties": ["keywords", "mailboxIds", "receivedAt", "subject", "size", "bodyValues", "htmlBody"],
+ | "fetchHTMLBodyValues": true
+ | },
+ | "c2"]
+ | ]
+ |}""".stripMargin)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ val responseAsJson = Json.parse(response)
+ .\("methodResponses")
+ .\(0).\(1)
+ .\("created")
+ .\("C42")
+
+ val messageId = responseAsJson
+ .\("id")
+ .get.asInstanceOf[JsString].value
+
+ assertThatJson(response)
+ .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+ .inPath("methodResponses")
+ .isEqualTo(
+ s""" [
+ | ["Email/import",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "created": {
+ | "C42": {
+ | "id": "$messageId",
+ | "blobId": "$messageId",
+ | "threadId": "$messageId",
+ | "size": 836
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "notFound": [],
+ | "list": [
+ | {
+ | "id": "$messageId",
+ | "htmlBody": [
+ | {
+ | "charset": "utf-8",
+ | "size": 39,
+ | "partId": "2",
+ | "blobId": "${messageId}_2",
+ | "type": "text/html"
+ | }
+ | ],
+ | "size": 836,
+ | "keywords": {},
+ | "subject": "MultiAttachment",
+ | "mailboxIds": {
+ | "${mailboxId.serialize()}": true
+ | },
+ | "receivedAt": "$${json-unit.ignore}",
+ | "bodyValues": {
+ | "2": {
+ | "value": "<p>Send<br/>concerted from html</p>\\r\\n\\r\\n",
+ | "isEncodingProblem": false,
+ | "isTruncated": false
+ | }
+ | }
+ | }
+ | ]
+ | }, "c2"]
+ | ]""".stripMargin)
+ }
+
@Test
def importShouldEnforceQuotas(server: GuiceJamesServer): Unit = {
val quotaProbe = server.getProbe(classOf[QuotaProbesImpl])
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 8bc7194ef9..f302bb2a58 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
@@ -385,6 +385,57 @@ trait EmailSetMethodContract {
.isEqualTo("[\"${json-unit.ignore}\"]")
}
+ @Test
+ def emailSetCreateShouldSucceedWithEmptyKeywords(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
+ | },
+ | "keywords":{},
+ | "to": [{"email": "rcpt1@apache.org"}, {"email": "rcpt2@apache.org"}],
+ | "from": [{"email": "${BOB.asString}"}]
+ | }
+ | }
+ | }, "c1"],
+ | ["Email/get",
+ | {
+ | "accountId": "$ACCOUNT_ID",
+ | "ids": ["#aaaaaa"],
+ | "properties": ["sentAt", "messageId"]
+ | },
+ | "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].sentAt")
+ .isEqualTo("\"${json-unit.ignore}\"")
+ assertThatJson(response)
+ .inPath("methodResponses[1][1].list.[0].messageId")
+ .isEqualTo("[\"${json-unit.ignore}\"]")
+ }
+
@Test
def createShouldFailWhenBadJsonPayloadForSpecificHeader(server: GuiceJamesServer): Unit = {
val bobPath = MailboxPath.inbox(BOB)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailImport.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailImport.scala
index 5aad5e34f9..4447bd8f97 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailImport.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailImport.scala
@@ -19,6 +19,8 @@
package org.apache.james.jmap.mail
+import java.time.ZonedDateTime
+
import org.apache.james.jmap.core.{AccountId, SetError, UTCDate, UuidState}
import org.apache.james.jmap.method.WithAccountId
import org.apache.james.mailbox.model.MailboxId
@@ -28,10 +30,11 @@ case class EmailImportRequest(accountId: AccountId,
case class EmailImport(blobId: BlobId,
mailboxIds: MailboxIds,
- keywords: Keywords,
- receivedAt: UTCDate) {
+ keywords: Option[Keywords],
+ receivedAt: Option[UTCDate]) {
def validate: Either[IllegalArgumentException, ValidatedEmailImport] = mailboxIds match {
- case MailboxIds(List(mailboxId)) => scala.Right(ValidatedEmailImport(blobId, mailboxId, keywords, receivedAt))
+ case MailboxIds(List(mailboxId)) => scala.Right(ValidatedEmailImport(blobId, mailboxId, keywords.getOrElse(Keywords(Set())),
+ receivedAt.getOrElse(UTCDate(ZonedDateTime.now()))))
case _ => Left(new IllegalArgumentException("Email/import so far only supports a single mailboxId"))
}
}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Keywords.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Keywords.scala
index 9967665b96..836a2f0a39 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Keywords.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Keywords.scala
@@ -48,7 +48,9 @@ object ToKeyword {
object KeywordsValidator {
val THROW_ON_IMAP_NON_EXPOSED_KEYWORDS: KeywordsValidator = (keywords: Set[Keyword]) => {
val exposedKeywords = keywords.filter(_.isExposedImapKeyword)
- if (exposedKeywords.isEmpty || exposedKeywords.size != keywords.size) {
+ if (keywords.isEmpty) {
+ Success((): Unit)
+ } else if (exposedKeywords.isEmpty || exposedKeywords.size != keywords.size) {
Failure(new IllegalArgumentException("Does not allow to update 'Deleted' or 'Recent' flag"))
} else {
Success((): Unit)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
index a9f34ada3e..35b25b3588 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSetCreatePerformer.scala
@@ -28,7 +28,7 @@ import javax.mail.Flags
import org.apache.james.jmap.JMAPConfiguration
import org.apache.james.jmap.api.model.Size.sanitizeSize
import org.apache.james.jmap.core.SetError.SetErrorDescription
-import org.apache.james.jmap.core.{JmapRfc8621Configuration, Properties, SetError, UTCDate}
+import org.apache.james.jmap.core.{Properties, SetError, UTCDate}
import org.apache.james.jmap.json.EmailSetSerializer
import org.apache.james.jmap.mail.{BlobId, EmailCreationId, EmailCreationRequest, EmailCreationResponse, EmailSetRequest, ThreadId}
import org.apache.james.jmap.method.EmailSetCreatePerformer.{CreationFailure, CreationResult, CreationResults, CreationSuccess}
@@ -82,8 +82,7 @@ class EmailSetCreatePerformer @Inject()(serializer: EmailSetSerializer,
blobResolvers: BlobResolvers,
htmlTextExtractor: HtmlTextExtractor,
mailboxManager: MailboxManager,
- configuration: JMAPConfiguration,
- configurationRfc8621: JmapRfc8621Configuration) {
+ configuration: JMAPConfiguration) {
def create(request: EmailSetRequest, mailboxSession: MailboxSession): SMono[CreationResults] =
SFlux.fromIterable(request.create.getOrElse(Map()))
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org