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