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:03 UTC

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

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