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