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 2021/04/12 12:42:00 UTC

[james-project] branch master updated (fe91474 -> f1cce70)

This is an automated email from the ASF dual-hosted git repository.

btellier pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git.


    from fe91474  JAMES-3417 JMAP documentation: Identity/get ids is now supported
     new 066aa43  [Refactoring] JMAP: Alphabetically order methods in guice modules
     new 38503f7  JAMES-3532 Update JMAP documentation: Email/set create only support single mailbox
     new 5122498  JAMES-3532 Implement Email/import
     new f1cce70  JAMES-3532 Document implementation of Email/import

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   20 +-
 .../rfc8621/contract/EmailImportContract.scala     | 1072 ++++++++++++++++++++
 ...tMethodTest.java => MemoryEmailImportTest.java} |   11 +-
 .../doc/specs/spec/mail/message.mdown              |    9 +-
 .../james/jmap/json/EmailSetSerializer.scala       |   12 +
 .../org/apache/james/jmap/mail/EmailImport.scala   |   49 +
 .../james/jmap/method/EmailImportMethod.scala      |  186 ++++
 7 files changed, 1344 insertions(+), 15 deletions(-)
 create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailImportContract.scala
 copy server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/{MemoryEmailSetMethodTest.java => MemoryEmailImportTest.java} (86%)
 create mode 100644 server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailImport.scala
 create mode 100644 server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailImportMethod.scala

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


[james-project] 03/04: JAMES-3532 Implement Email/import

Posted by bt...@apache.org.
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 5122498536eb4cdc2878a4fc758227f36f317f42
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Sun Apr 4 14:55:19 2021 +0700

    JAMES-3532 Implement Email/import
    
    Email/import can be used by a client to create emails that do not
    match the convenience mime format defined in JMAP RFC-8621 (textBody
     + htmlBody + attachment) and that do not wish to specify it through
    the bodyStructure properties.
    
    (Please also note that Email/set create do not yet support
    bodyStructure properties)
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |    2 +
 .../rfc8621/contract/EmailImportContract.scala     | 1072 ++++++++++++++++++++
 .../jmap/rfc8621/memory/MemoryEmailImportTest.java |   54 +
 .../james/jmap/json/EmailSetSerializer.scala       |   12 +
 .../org/apache/james/jmap/mail/EmailImport.scala   |   49 +
 .../james/jmap/method/EmailImportMethod.scala      |  186 ++++
 6 files changed, 1375 insertions(+)

diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
index d32d5c0..08dd000 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
@@ -38,6 +38,7 @@ import org.apache.james.jmap.jwt.JWTAuthenticationStrategy;
 import org.apache.james.jmap.method.CoreEchoMethod;
 import org.apache.james.jmap.method.EmailChangesMethod;
 import org.apache.james.jmap.method.EmailGetMethod;
+import org.apache.james.jmap.method.EmailImportMethod;
 import org.apache.james.jmap.method.EmailQueryMethod;
 import org.apache.james.jmap.method.EmailSetMethod;
 import org.apache.james.jmap.method.EmailSubmissionSetMethod;
@@ -87,6 +88,7 @@ public class RFC8621MethodsModule extends AbstractModule {
         Multibinder<Method> methods = Multibinder.newSetBinder(binder(), Method.class);
         methods.addBinding().to(CoreEchoMethod.class);
         methods.addBinding().to(EmailChangesMethod.class);
+        methods.addBinding().to(EmailImportMethod.class);
         methods.addBinding().to(EmailGetMethod.class);
         methods.addBinding().to(EmailQueryMethod.class);
         methods.addBinding().to(EmailSetMethod.class);
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
new file mode 100644
index 0000000..a44af7a
--- /dev/null
+++ 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
@@ -0,0 +1,1072 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import java.nio.charset.StandardCharsets
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.{SC_CREATED, SC_OK}
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.core.UTCDate
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture._
+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.mime4j.dom.Message
+import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl}
+import org.apache.james.utils.DataProbeImpl
+import org.assertj.core.api.SoftAssertions
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json.{JsString, Json}
+
+trait EmailImportContract {
+  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])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+        .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build
+  }
+
+  def randomMessageId: MessageId
+
+  def randomMailboxId: MailboxId
+
+  @Test
+  def importShouldReturnUnknownMethodWhenMissingCoreCapability(): Unit = {
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body("""{
+              |  "using": ["urn:ietf:params:jmap:mail"],
+              |  "methodCalls": [
+              |    [
+              |      "Email/import",
+              |      {
+              |        "arg1": "arg1data",
+              |        "arg2": "arg2data"
+              |      },
+              |      "c1"
+              |    ]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def importShouldReturnUnknownMethodWhenMissingMailCapability(): Unit = {
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body("""{
+              |  "using": ["urn:ietf:params:jmap:core"],
+              |  "methodCalls": [
+              |    [
+              |      "Email/import",
+              |      {
+              |        "arg1": "arg1data",
+              |        "arg2": "arg2data"
+              |      },
+              |      "c1"
+              |    ]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mail"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def importShouldAddTheMailInTheMailbox(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": {
+              |               "toto": 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": {
+         |                            "toto": true
+         |                        },
+         |                        "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 importShouldAddTheMailsInTheMailbox(server: GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val uploadResponse1: 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 uploadResponse2: String = `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(ClassLoader.getSystemResourceAsStream("eml/html.eml"))
+    .when
+      .post(s"/upload/$ACCOUNT_ID/")
+    .`then`
+      .statusCode(SC_CREATED)
+      .extract
+      .body
+      .asString
+
+    val blobId1: String = Json.parse(uploadResponse1).\("blobId").get.asInstanceOf[JsString].value
+    val blobId2: String = Json.parse(uploadResponse2).\("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": "$blobId1",
+              |             "mailboxIds": {
+              |               "${mailboxId.serialize()}": true
+              |             },
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           },
+              |           "C43": {
+              |             "blobId": "$blobId2",
+              |             "mailboxIds": {
+              |               "${mailboxId.serialize()}": true
+              |             },
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"],
+              |    ["Email/get",
+              |     {
+              |       "accountId": "$ACCOUNT_ID",
+              |       "ids": ["#C42", "#C43"],
+              |       "properties": ["subject"],
+              |       "fetchHTMLBodyValues": true
+              |     },
+              |     "c2"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    val messageId1 = Json.parse(response)
+      .\("methodResponses")
+      .\(0).\(1)
+      .\("created")
+      .\("C42")
+      .\("id")
+      .get.asInstanceOf[JsString].value
+
+    val messageId2 = Json.parse(response)
+      .\("methodResponses")
+      .\(0).\(1)
+      .\("created")
+      .\("C43")
+      .\("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": "$messageId1",
+         |                        "blobId": "$messageId1",
+         |                        "threadId": "$messageId1",
+         |                        "size": 836
+         |                    },
+         |                    "C43": {
+         |                        "id": "$messageId2",
+         |                        "blobId": "$messageId2",
+         |                        "threadId": "$messageId2",
+         |                        "size": 2727
+         |                    }
+         |                }
+         |            },
+         |            "c1"
+         |        ],
+         |        [
+         |            "Email/get",
+         |            {
+         |                "accountId": "$ACCOUNT_ID",
+         |                "notFound": [],
+         |                "list": [
+         |                    {
+         |                        "subject": "MultiAttachment",
+         |                        "id": "$messageId1"
+         |                    },
+         |                    {
+         |                        "subject": "MultiAttachment",
+         |                        "id": "$messageId2"
+         |                    }
+         |                ]
+         |            },
+         |            "c2"
+         |        ]
+         |    ]""".stripMargin)
+  }
+
+  @Test
+  def importShouldDisplayOldAndNewState(server: GuiceJamesServer): Unit = {
+    val oldState: String = retrieveEmailState
+
+    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": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    val newState: String = retrieveEmailState
+    val importOldState = Json.parse(response)
+      .\("methodResponses")
+      .\(0).\(1)
+      .\("oldState")
+      .get.asInstanceOf[JsString].value
+    val importNewState = Json.parse(response)
+      .\("methodResponses")
+      .\(0).\(1)
+      .\("newState")
+      .get.asInstanceOf[JsString].value
+
+    SoftAssertions.assertSoftly(softly => {
+      softly.assertThat(importOldState).isEqualTo(oldState)
+      softly.assertThat(importNewState).isEqualTo(newState)
+    })
+  }
+
+  def retrieveEmailState: String = `with`()
+    .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .body(
+      s"""{
+         |  "using": [
+         |  "urn:ietf:params:jmap:core",
+         |  "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/get", {
+         |      "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids":[]
+         |    }, "c1"]
+         |  ]
+         |}""".stripMargin)
+    .post
+  .`then`()
+    .extract()
+    .jsonPath()
+    .get("methodResponses[0][1].state")
+
+  @Test
+  def importShouldFailWhenMailboxNotOwned(server: GuiceJamesServer): Unit = {
+    val alicePath = MailboxPath.inbox(ALICE)
+    val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(alicePath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val uploadResponse: String = `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body("whatever")
+    .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": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+      s"""[
+           |  "Email/import",
+           |  {
+           |    "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |    "notCreated":{
+           |      "C42":{
+           |        "type":"notFound",
+           |        "description":"Mailbox $mailboxId can not be found"
+           |      }
+           |    }
+           |  },
+           |  "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def importShouldSucceedWhenMailboxDelegated(server: GuiceJamesServer): Unit = {
+    val andrePath = MailboxPath.inbox(ANDRE)
+    val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Insert, Right.Lookup, Right.Read))
+
+    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": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".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[0]")
+      .isEqualTo(
+      s"""[
+           |  "Email/import",
+           |  {
+           |    "accountId":"$ACCOUNT_ID",
+           |    "created":{
+           |      "C42":{
+           |        "id":"$messageId",
+           |        "blobId":"$messageId",
+           |        "threadId":"$messageId",
+           |        "size":836
+           |      }
+           |    }
+           |  },
+           |  "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def importShouldFailWhenBlobNotOwned(server: GuiceJamesServer): Unit = {
+    val andrePath = MailboxPath.inbox(ANDRE)
+    val andreId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+    val bobPath = MailboxPath.inbox(BOB)
+    val bobId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(ANDRE.asString, andrePath, AppendCommand.from(message))
+      .getMessageId
+    val blobId: String = messageId.serialize()
+
+    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": {
+              |               "${bobId.serialize()}": true
+              |             },
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+      s"""[
+           |  "Email/import",
+           |  {
+           |    "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |    "notCreated":{
+           |      "C42":{
+           |        "type":"notFound",
+           |        "description":"Blob BlobId($blobId) could not be found"
+           |      }
+           |    }
+           |  },
+           |  "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def importShouldSucceedWhenBlobDelegated(server: GuiceJamesServer): Unit = {
+    val andrePath = MailboxPath.inbox(ANDRE)
+    val andreId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+    val bobPath = MailboxPath.inbox(BOB)
+    val bobId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val blobId: String = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(ANDRE.asString, andrePath, AppendCommand.from(message))
+      .getMessageId.serialize()
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, BOB.asString, new MailboxACL.Rfc4314Rights(Right.Insert, Right.Lookup, Right.Read))
+
+    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": {
+              |               "${bobId.serialize()}": true
+              |             },
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".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[0]")
+      .isEqualTo(
+      s"""[
+           |  "Email/import",
+           |  {
+           |    "accountId":"$ACCOUNT_ID",
+           |    "created":{
+           |      "C42":{
+           |        "id":"$messageId",
+           |        "blobId":"$messageId",
+           |        "threadId":"$messageId",
+           |        "size":85
+           |      }
+           |    }
+           |  },
+           |  "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def importShouldFailWhenNoMailboxes(): Unit = {
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val uploadResponse: String = `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body("whatever")
+    .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": {},
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+      s"""[
+           |  "Email/import",
+           |  {
+           |    "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |    "notCreated":{
+           |      "C42":{
+           |        "type":"invalidArguments",
+           |        "description":"Email/import so far only supports a single mailboxId"
+           |      }
+           |    }
+           |  },
+           |  "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def importShouldFailWhenTooManyMailboxes(): Unit = {
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val uploadResponse: String = `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body("whatever")
+    .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": {
+              |               "${randomMailboxId.serialize()}": true,
+              |               "${randomMailboxId.serialize()}": true
+              |             },
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState", "methodResponses[1][1].state")
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+      s"""[
+           |  "Email/import",
+           |  {
+           |    "accountId":"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+           |    "notCreated":{
+           |      "C42":{
+           |        "type":"invalidArguments",
+           |        "description":"Email/import so far only supports a single mailboxId"
+           |      }
+           |    }
+           |  },
+           |  "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def importShouldFailWhenBlobNotFound(server: GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val blobId: String = randomMessageId.serialize()
+
+    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": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].oldState", "methodResponses[0][1].newState")
+      .inPath("methodResponses[0][1]")
+      .isEqualTo(
+      s"""{
+           |  "accountId":"$ACCOUNT_ID",
+           |  "notCreated":{
+           |    "C42":{
+           |      "type":"notFound",
+           |      "description":"Blob BlobId($blobId) could not be found"
+           |    }
+           |  }
+           |}""".stripMargin)
+  }
+
+  @Test
+  def importShouldFailWhenInvalidAccountId(server: GuiceJamesServer): Unit = {
+    val bobPath = MailboxPath.inbox(BOB)
+    val mailboxId: MailboxId = server.getProbe(classOf[MailboxProbeImpl]).createMailbox(bobPath)
+    val receivedAt = ZonedDateTime.now().minusDays(1)
+
+    val blobId: String = randomMessageId.serialize()
+
+    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": "bad",
+              |        "emails": {
+              |           "C42": {
+              |             "blobId": "$blobId",
+              |             "mailboxIds": {
+              |               "${mailboxId.serialize()}": true
+              |             },
+              |             "keywords": {
+              |               "toto": true
+              |             },
+              |             "receivedAt": "$receivedAtString"
+              |           }
+              |         }
+              |      },
+              |      "c1"]
+              |  ]
+              |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo("""["error",{"type":"accountNotFound"},"c1"]""")
+  }
+}
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/MemoryEmailImportTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailImportTest.java
new file mode 100644
index 0000000..a96ad6c
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryEmailImportTest.java
@@ -0,0 +1,54 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+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.EmailImportContract;
+import org.apache.james.mailbox.inmemory.InMemoryId;
+import org.apache.james.mailbox.inmemory.InMemoryMessageId;
+import org.apache.james.mailbox.model.MailboxId;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryEmailImportTest implements EmailImportContract {
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+        .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+            .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+
+    @Override
+    public MessageId randomMessageId() {
+        return InMemoryMessageId.of(ThreadLocalRandom.current().nextInt(100000) + 100);
+    }
+
+    @Override
+    public MailboxId randomMailboxId() {
+        return InMemoryId.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 b5b99f9..646963b 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
@@ -29,6 +29,7 @@ import org.apache.james.jmap.core.Id.IdConstraint
 import org.apache.james.jmap.core.{Id, SetError, State, UTCDate}
 import org.apache.james.jmap.mail.KeywordsFactory.STRICT_KEYWORDS_FACTORY
 import org.apache.james.jmap.mail.{AddressesHeaderValue, AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, Attachment, BlobId, Charset, ClientBody, ClientCid, ClientEmailBodyValue, ClientPartId, DateHeaderValue, DestroyIds, Disposition, EmailAddress, EmailAddressGroup, EmailCreationId, EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailSetRequest, EmailSetResponse, EmailSetUpdate, EmailerName, GroupName, GroupedAd [...]
+import org.apache.james.jmap.mail.{AddressesHeaderValue, AsAddresses, AsDate, AsGroupedAddresses, AsMessageIds, AsRaw, AsText, AsURLs, Attachment, BlobId, Charset, ClientBody, ClientCid, ClientEmailBodyValue, ClientPartId, DateHeaderValue, DestroyIds, Disposition, EmailAddress, EmailAddressGroup, EmailCreationRequest, EmailCreationResponse, EmailHeader, EmailHeaderName, EmailHeaderValue, EmailImport, EmailImportRequest, EmailImportResponse, EmailSetRequest, EmailSetResponse, EmailSetUpda [...]
 import org.apache.james.mailbox.model.{MailboxId, MessageId}
 import play.api.libs.json.{Format, JsArray, JsBoolean, JsError, JsNull, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, OWrites, Reads, Writes}
 
@@ -407,6 +408,13 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
 
     case _ => JsError("Expecting a JsObject to represent a creation request")
   }
+  private implicit val emailImportReads: Reads[EmailImport] = Json.reads[EmailImport]
+  private implicit val importMapRead: Reads[Map[EmailCreationId, EmailImport]] =
+    Reads.mapReads[EmailCreationId, EmailImport]{s => refineV[IdConstraint](s)
+      .fold(e => JsError(s"email creationId needs to match id constraints: $e"),
+        id => JsSuccess(EmailCreationId(id))) } (emailImportReads)
+  private implicit val emailImportRequestReads: Reads[EmailImportRequest] = Json.reads[EmailImportRequest]
+  private implicit val emailImportResponseWrite: OWrites[EmailImportResponse] = Json.writes[EmailImportResponse]
 
   def deserialize(input: JsValue): JsResult[EmailSetRequest] = Json.fromJson[EmailSetRequest](input)
 
@@ -415,4 +423,8 @@ class EmailSetSerializer @Inject()(messageIdFactory: MessageId.Factory, mailboxI
   def deserializeEmailSetUpdate(input: JsValue): JsResult[EmailSetUpdate] = Json.fromJson[EmailSetUpdate](input)
 
   def serialize(response: EmailSetResponse): JsObject = Json.toJsObject(response)
+
+  def deserializeEmailImportRequest(input: JsValue): JsResult[EmailImportRequest] = Json.fromJson[EmailImportRequest](input)
+
+  def serializeEmailImportResponse(response: EmailImportResponse): JsObject = Json.toJsObject(response)
 }
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
new file mode 100644
index 0000000..b67460d
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailImport.scala
@@ -0,0 +1,49 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ * ***************************************************************/
+
+package org.apache.james.jmap.mail
+
+import org.apache.james.jmap.core.{AccountId, SetError, State, UTCDate}
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MailboxId
+
+case class EmailImportRequest(accountId: AccountId,
+                              emails: Map[EmailCreationId, EmailImport]) extends WithAccountId
+
+case class EmailImport(blobId: BlobId,
+                       mailboxIds: MailboxIds,
+                       keywords: Keywords,
+                       receivedAt: UTCDate) {
+  def validate: Either[IllegalArgumentException, ValidatedEmailImport] = mailboxIds match {
+    case MailboxIds(List(mailboxId)) => scala.Right(ValidatedEmailImport(blobId, mailboxId, keywords, receivedAt))
+    case _ => Left(new IllegalArgumentException("Email/import so far only supports a single mailboxId"))
+  }
+}
+
+case class ValidatedEmailImport(blobId: BlobId,
+                                mailboxId: MailboxId,
+                                keywords: Keywords,
+                                receivedAt: UTCDate)
+
+case class EmailImportResponse(accountId: AccountId,
+                               oldState: State,
+                               newState: State,
+                               created: Option[Map[EmailCreationId, EmailCreationResponse]],
+                               notCreated: Option[Map[EmailCreationId, SetError]])
+
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailImportMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailImportMethod.scala
new file mode 100644
index 0000000..b90a98d
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailImportMethod.scala
@@ -0,0 +1,186 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ * http://www.apache.org/licenses/LICENSE-2.0                   *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.method
+
+import java.util.Date
+
+import eu.timepit.refined.auto._
+import javax.inject.Inject
+import org.apache.james.jmap.api.change.EmailChangeRepository
+import org.apache.james.jmap.api.model.{AccountId => JavaAccountId}
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JAMES_SHARES, JMAP_CORE, JMAP_MAIL}
+import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{ClientId, Id, Invocation, ServerId, SetError, State}
+import org.apache.james.jmap.json.{EmailSetSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.{BlobId, Email, EmailCreationId, EmailCreationResponse, EmailImport, EmailImportRequest, EmailImportResponse, ValidatedEmailImport}
+import org.apache.james.jmap.method.EmailImportMethod.{ImportFailure, ImportResult, ImportResults, ImportSuccess, ImportWithBlob}
+import org.apache.james.jmap.routes.{Blob, BlobNotFoundException, BlobResolvers, ProcessingContext, SessionSupplier}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.exception.MailboxNotFoundException
+import org.apache.james.mailbox.{MailboxManager, MailboxSession, MessageManager}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.codec.DecodeMonitor
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.mime4j.stream.MimeConfig
+import org.reactivestreams.Publisher
+import play.api.libs.json.JsError
+import reactor.core.scala.publisher.{SFlux, SMono}
+import reactor.core.scheduler.Schedulers
+
+import scala.util.{Try, Using}
+
+object EmailImportMethod {
+  case class ImportWithBlob(id: EmailCreationId, request: EmailImport, blob: Blob)
+  case class ImportResults(results: Seq[ImportResult]) {
+    def created: Option[Map[EmailCreationId, EmailCreationResponse]] =
+      Option(results.flatMap{
+        case result: ImportSuccess => Some((result.clientId, result.response))
+        case _ => None
+      }.toMap)
+        .filter(_.nonEmpty)
+
+    def notCreated: Option[Map[EmailCreationId, SetError]] = {
+      Option(results.flatMap{
+        case failure: ImportFailure => Some((failure.clientId, failure.asMessageSetError))
+        case _ => None
+      }
+        .toMap)
+        .filter(_.nonEmpty)
+    }
+  }
+  trait ImportResult
+  case class ImportSuccess(clientId: EmailCreationId, response: EmailCreationResponse) extends ImportResult
+  case class ImportFailure(clientId: EmailCreationId, e: Throwable) extends ImportResult {
+    def asMessageSetError: SetError = e match {
+      case e: BlobNotFoundException => SetError.notFound(SetErrorDescription(s"Blob ${e.blobId} could not be found"))
+      case e: MailboxNotFoundException => SetError.notFound(SetErrorDescription("Mailbox " + e.getMessage))
+      case e: IllegalArgumentException => SetError.invalidArguments(SetErrorDescription(e.getMessage))
+      case _ => SetError.serverFail(SetErrorDescription(e.getMessage))
+    }
+  }
+}
+
+class EmailImportMethod @Inject() (val metricFactory: MetricFactory,
+                                   val sessionSupplier: SessionSupplier,
+                                   val blobResolvers: BlobResolvers,
+                                   val serializer: EmailSetSerializer,
+                                   val mailboxManager: MailboxManager,
+                                   val emailChangeRepository: EmailChangeRepository) extends MethodRequiringAccountId[EmailImportRequest] {
+  override val methodName: MethodName = MethodName("Email/import")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_CORE, JMAP_MAIL)
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, EmailImportRequest] =
+    serializer.deserializeEmailImportRequest(invocation.arguments.value).asEither
+      .left.map(errors => new IllegalArgumentException(ResponseSerializer.serialize(JsError(errors)).toString))
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier], invocation: InvocationWithContext, mailboxSession: MailboxSession, request: EmailImportRequest): Publisher[InvocationWithContext] =
+    for {
+      oldState <- retrieveState(capabilities, mailboxSession)
+      importResults <- importEmails(request, mailboxSession)
+        .subscribeOn(Schedulers.elastic())
+      newState <- retrieveState(capabilities, mailboxSession)
+    } yield {
+      val updatedContext = updateProcessingContext(importResults, invocation.processingContext)
+      val importResponse = EmailImportResponse(
+        accountId = request.accountId,
+        oldState = oldState,
+        newState = newState,
+        created = importResults.created,
+        notCreated = importResults.notCreated)
+      InvocationWithContext(
+        Invocation(
+          methodName = methodName,
+          arguments = Arguments(serializer.serializeEmailImportResponse(importResponse)),
+          methodCallId = invocation.invocation.methodCallId),
+        updatedContext)
+    }
+
+  def updateProcessingContext(importResults: ImportResults, processingContext: ProcessingContext): ProcessingContext =
+    importResults.created.getOrElse(Map())
+      .foldLeft(processingContext) {
+        case (context, entry) =>
+          Id.validate(entry._2.id.serialize)
+            .fold(_ => context,
+              serverId => context.recordCreatedId(ClientId(entry._1.id), ServerId(serverId)))
+      }
+
+  private def importEmails(request: EmailImportRequest, mailboxSession: MailboxSession): SMono[ImportResults] =
+    SFlux.fromIterable(request.emails.toList)
+      .flatMap {
+        case creationId -> emailImport => resolveBlob(mailboxSession, creationId, emailImport)
+      }
+      .map {
+        case Right(emailImport) => importEmail(mailboxSession, emailImport)
+        case Left(e) => e
+      }.collectSeq()
+      .map(ImportResults)
+
+  private def importEmail(mailboxSession: MailboxSession, emailImport: ImportWithBlob): ImportResult = {
+    val either = for {
+      validatedRequest <- emailImport.request.validate
+      message <- asMessage(emailImport.blob)
+      response <- append(validatedRequest, message, mailboxSession)
+    } yield response
+
+    either.fold(e => ImportFailure(emailImport.id, e),
+      response => ImportSuccess(emailImport.id, response))
+  }
+
+  private def resolveBlob(mailboxSession: MailboxSession, creationId: EmailCreationId, emailImport: EmailImport): SMono[Either[ImportFailure, ImportWithBlob]] =
+    blobResolvers.resolve(emailImport.blobId, mailboxSession)
+      .map(blob => Right[ImportFailure, ImportWithBlob](ImportWithBlob(creationId, emailImport, blob)))
+      .onErrorResume(e => SMono.just(Left[ImportFailure, ImportWithBlob](ImportFailure(creationId, e))))
+
+  private def asMessage(blob: Blob): Either[Throwable, Message] = {
+    val defaultMessageBuilder = new DefaultMessageBuilder
+    defaultMessageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE)
+    defaultMessageBuilder.setDecodeMonitor(DecodeMonitor.SILENT)
+
+    Using(blob.content) {content => defaultMessageBuilder.parseMessage(content)}
+      .toEither
+  }
+
+  private def append(emailImport: ValidatedEmailImport, message: Message, mailboxSession: MailboxSession): Either[Throwable, EmailCreationResponse] =
+    Try(mailboxManager.getMailbox(emailImport.mailboxId, mailboxSession)
+      .appendMessage(AppendCommand.builder()
+        .recent()
+        .withFlags(emailImport.keywords.asFlags)
+        .withInternalDate(Date.from(emailImport.receivedAt.asUTC.toInstant))
+        .build(message),
+        mailboxSession))
+      .map(asEmailCreationResponse)
+      .toEither
+
+  private def asEmailCreationResponse(appendResult: MessageManager.AppendResult): EmailCreationResponse = {
+    val blobId: Option[BlobId] = BlobId.of(appendResult.getId.getMessageId).toOption
+    EmailCreationResponse(appendResult.getId.getMessageId, blobId, blobId, Email.sanitizeSize(appendResult.getSize))
+  }
+
+  private def retrieveState(capabilities: Set[CapabilityIdentifier], mailboxSession: MailboxSession): SMono[State] =
+    if (capabilities.contains(JAMES_SHARES)) {
+      SMono(emailChangeRepository.getLatestStateWithDelegation(JavaAccountId.fromUsername(mailboxSession.getUser)))
+        .map(State.fromJava)
+    } else {
+      SMono(emailChangeRepository.getLatestState(JavaAccountId.fromUsername(mailboxSession.getUser)))
+        .map(State.fromJava)
+    }
+}
\ No newline at end of file

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


[james-project] 02/04: JAMES-3532 Update JMAP documentation: Email/set create only support single mailbox

Posted by bt...@apache.org.
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 38503f7b6ca65268e64b2431462b23042d811603
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Sun Apr 4 10:50:07 2021 +0700

    JAMES-3532 Update JMAP documentation: Email/set create only support single mailbox
---
 server/protocols/jmap-rfc-8621/doc/specs/spec/mail/message.mdown | 3 +++
 1 file changed, 3 insertions(+)

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 1dd8012..ff5f425 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
@@ -888,6 +888,9 @@ For **create** and **update**:
   server-defined maximum.
 - `tooManyMailboxes`: The change to the set of Mailboxes that this Email is in would exceed a server-defined maximum.
 
+> :warning:
+> Email/set create will reject emails in several mailboxes
+
 ## Email/copy
 
 > :warning:

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


[james-project] 01/04: [Refactoring] JMAP: Alphabetically order methods in guice modules

Posted by bt...@apache.org.
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 066aa433c1f60985e7d0c1e78056a0205b538ec5
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Sun Apr 4 10:49:25 2021 +0700

    [Refactoring] JMAP: Alphabetically order methods in guice modules
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java       | 18 +++++++++---------
 1 file changed, 9 insertions(+), 9 deletions(-)

diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
index f76c17d..d32d5c0 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
@@ -86,21 +86,21 @@ public class RFC8621MethodsModule extends AbstractModule {
 
         Multibinder<Method> methods = Multibinder.newSetBinder(binder(), Method.class);
         methods.addBinding().to(CoreEchoMethod.class);
-        methods.addBinding().to(MailboxGetMethod.class);
-        methods.addBinding().to(MailboxQueryMethod.class);
-        methods.addBinding().to(MailboxSetMethod.class);
-        methods.addBinding().to(MailboxChangesMethod.class);
+        methods.addBinding().to(EmailChangesMethod.class);
         methods.addBinding().to(EmailGetMethod.class);
+        methods.addBinding().to(EmailQueryMethod.class);
         methods.addBinding().to(EmailSetMethod.class);
         methods.addBinding().to(EmailSubmissionSetMethod.class);
-        methods.addBinding().to(EmailQueryMethod.class);
-        methods.addBinding().to(EmailChangesMethod.class);
-        methods.addBinding().to(VacationResponseGetMethod.class);
-        methods.addBinding().to(VacationResponseSetMethod.class);
         methods.addBinding().to(IdentityGetMethod.class);
+        methods.addBinding().to(MailboxChangesMethod.class);
+        methods.addBinding().to(MailboxGetMethod.class);
+        methods.addBinding().to(MailboxQueryMethod.class);
+        methods.addBinding().to(MailboxSetMethod.class);
+        methods.addBinding().to(MDNParseMethod.class);
         methods.addBinding().to(ThreadChangesMethod.class);
         methods.addBinding().to(ThreadGetMethod.class);
-        methods.addBinding().to(MDNParseMethod.class);
+        methods.addBinding().to(VacationResponseGetMethod.class);
+        methods.addBinding().to(VacationResponseSetMethod.class);
 
         Multibinder<JMAPRoutes> routes = Multibinder.newSetBinder(binder(), JMAPRoutes.class);
         routes.addBinding().to(SessionRoutes.class);

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


[james-project] 04/04: JAMES-3532 Document implementation of Email/import

Posted by bt...@apache.org.
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 f1cce7079ee84ddedc84695536fb046c33dfcd60
Author: Benoit Tellier <bt...@linagora.com>
AuthorDate: Sun Apr 4 14:57:56 2021 +0700

    JAMES-3532 Document implementation of Email/import
---
 server/protocols/jmap-rfc-8621/doc/specs/spec/mail/message.mdown | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

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 ff5f425..cb11c2f 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
@@ -906,7 +906,7 @@ For successfully copied Email objects, the *created* response contains the *id*,
 ## Email/import
 
 > :warning:
-> Not implemented yet
+> Partially implemented
 
 The *Email/import* method adds messages [@!RFC5322] to the set of Emails in an account. The server MUST support messages with Email Address Internationalization (EAI) headers [@!RFC6532]. The messages must first be uploaded as blobs using the standard upload mechanism. The method takes the following arguments:
 
@@ -923,6 +923,10 @@ An **EmailImport** object has the following properties:
   The id of the blob containing the raw message [@!RFC5322].
 - **mailboxIds**: `Id[Boolean]`
   The ids of the Mailboxes to assign this Email to. At least one Mailbox MUST be given.
+
+> :warning:
+> So far, only a single mailboxId is supported
+
 - **keywords**: `String[Boolean]` (default: \{\})
   The keywords to apply to the Email.
 - **receivedAt**: `UTCDate` (default: time of most recent Received header, or time of import on server if none)

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org