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 3019c6d532e640a72289058b25daf062a8fdcd25
Author: Benoit Tellier <[email protected]>
AuthorDate: Thu Sep 10 17:35:16 2020 +0700

    JAMES-3373 Implement Email and EmailBodyPart download
---
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   5 +-
 .../distributed/DistributedDownloadTest.java       |  64 ++++
 .../jmap/rfc8621/contract/DownloadContract.scala   | 362 +++++++++++++++++++++
 .../jmap/rfc8621/memory/MemoryDownloadTest.java    |  47 +++
 .../org/apache/james/jmap/mail/EmailBodyPart.scala |  24 +-
 .../apache/james/jmap/routes/DownloadRoutes.scala  | 240 ++++++++++++++
 6 files changed, 736 insertions(+), 6 deletions(-)

diff --git 
a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
 
b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
index 31d6170..3f29127 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
@@ -44,6 +44,7 @@ import org.apache.james.jmap.method.VacationResponseGetMethod;
 import org.apache.james.jmap.method.VacationResponseSetMethod;
 import org.apache.james.jmap.method.ZoneIdProvider;
 import org.apache.james.jmap.model.JmapRfc8621Configuration;
+import org.apache.james.jmap.routes.DownloadRoutes;
 import org.apache.james.jmap.routes.JMAPApiRoutes;
 import org.apache.james.metrics.api.MetricFactory;
 import org.apache.james.utils.PropertiesProvider;
@@ -75,8 +76,8 @@ public class RFC8621MethodsModule extends AbstractModule {
     }
 
     @ProvidesIntoSet
-    JMAPRoutesHandler routesHandler(SessionRoutes sessionRoutes, JMAPApiRoutes 
jmapApiRoutes) {
-        return new JMAPRoutesHandler(Version.RFC8621, jmapApiRoutes, 
sessionRoutes);
+    JMAPRoutesHandler routesHandler(SessionRoutes sessionRoutes, JMAPApiRoutes 
jmapApiRoutes, DownloadRoutes downloadRoutes) {
+        return new JMAPRoutesHandler(Version.RFC8621, jmapApiRoutes, 
sessionRoutes, downloadRoutes);
     }
 
     @Provides
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedDownloadTest.java
 
b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedDownloadTest.java
new file mode 100644
index 0000000..04e057b
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedDownloadTest.java
@@ -0,0 +1,64 @@
+/****************************************************************
+ * 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.distributed;
+
+import org.apache.james.CassandraExtension;
+import org.apache.james.CassandraRabbitMQJamesConfiguration;
+import org.apache.james.CassandraRabbitMQJamesServerMain;
+import org.apache.james.DockerElasticSearchExtension;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.DownloadContract;
+import org.apache.james.mailbox.cassandra.ids.CassandraMessageId;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.modules.AwsS3BlobStoreExtension;
+import org.apache.james.modules.RabbitMQExtension;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.apache.james.modules.blobstore.BlobStoreConfiguration;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.datastax.driver.core.utils.UUIDs;
+
+public class DistributedDownloadTest implements DownloadContract {
+    public static final CassandraMessageId.Factory MESSAGE_ID_FACTORY = new 
CassandraMessageId.Factory();
+
+    @RegisterExtension
+    static JamesServerExtension testExtension = new 
JamesServerBuilder<CassandraRabbitMQJamesConfiguration>(tmpDir ->
+        CassandraRabbitMQJamesConfiguration.builder()
+            .workingDirectory(tmpDir)
+            .configurationFromClasspath()
+            .blobStore(BlobStoreConfiguration.builder()
+                .s3()
+                .disableCache()
+                .deduplication())
+            .build())
+        .extension(new DockerElasticSearchExtension())
+        .extension(new CassandraExtension())
+        .extension(new RabbitMQExtension())
+        .extension(new AwsS3BlobStoreExtension())
+        .server(configuration -> 
CassandraRabbitMQJamesServerMain.createServer(configuration)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+
+    @Override
+    public MessageId randomMessageId() {
+        return MESSAGE_ID_FACTORY.of(UUIDs.timeBased());
+    }
+}
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/DownloadContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/DownloadContract.scala
new file mode 100644
index 0000000..729107c
--- /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/DownloadContract.scala
@@ -0,0 +1,362 @@
+/****************************************************************
+ * 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.io.ByteArrayInputStream
+import java.nio.charset.StandardCharsets
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured.{`given`, requestSpecification}
+import org.apache.commons.io.IOUtils
+import org.apache.http.HttpStatus.{SC_NOT_FOUND, SC_OK}
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.DownloadContract.accountId
+import 
org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, 
BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxPath, MessageId}
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.assertj.core.api.Assertions.assertThat
+import org.hamcrest.Matchers.{containsString, emptyOrNullString}
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+object DownloadContract {
+  val accountId = 
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
+}
+
+trait DownloadContract {
+  @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
+
+  @Test
+  def downloadMessage(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+
+    val response = `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}")
+    .`then`
+      .statusCode(SC_OK)
+      .contentType("message/rfc822")
+      .extract
+      .body
+      .asString
+
+    val expectedResponse: String = 
IOUtils.toString(ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml"),
+      StandardCharsets.UTF_8)
+    assertThat(new 
ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8)))
+      .hasContent(expectedResponse)
+  }
+
+  @Test
+  def downloadPart(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    val response = `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_3")
+    .`then`
+      .statusCode(SC_OK)
+      .contentType("text/plain")
+      .extract
+      .body
+      .asString
+
+    val expectedResponse: String =
+      """-----BEGIN RSA PRIVATE KEY-----
+        |MIIEogIBAAKCAQEAx7PG0+E//EMpm7IgI5Q9TMDSFya/1hE+vvTJrk0iGFllPeHL
+        |A5/VlTM0YWgG6X50qiMfE3VLazf2c19iXrT0mq/21PZ1wFnogv4zxUNaih+Bng62
+        |F0SyruE/O/Njqxh/Ccq6K/e05TV4T643USxAeG0KppmYW9x8HA/GvV832apZuxkV
+        |i6NVkDBrfzaUCwu4zH+HwOv/pI87E7KccHYC++Biaj3
+        |""".stripMargin
+    assertThat(new 
ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8)))
+      .hasContent(expectedResponse)
+  }
+
+  @Test
+  def userCanSpecifyContentTypeWhenDownloadingMessage(server: 
GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .queryParam("contentType", "text/plain")
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}")
+    .`then`
+      .statusCode(SC_OK)
+      .contentType("text/plain")
+  }
+
+  @Test
+  def userCanSpecifyContentTypeWhenDownloadingPart(server: GuiceJamesServer): 
Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .queryParam("contentType", "text/markdown")
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_3")
+    .`then`
+      .statusCode(SC_OK)
+      .contentType("text/markdown")
+  }
+
+  @Test
+  def downloadPartShouldDiscardNameWhenNotSuppliedByTheClient(server: 
GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_3")
+    .`then`
+      .statusCode(SC_OK)
+      .header("Content-Disposition",  emptyOrNullString())
+  }
+
+  @Test
+  def userCanSpecifyNameWhenDownloadingPart(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .queryParam("name", "gabouzomeuh.txt")
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_3")
+    .`then`
+      .statusCode(SC_OK)
+      .header("Content-Disposition", 
containsString("filename=\"gabouzomeuh.txt\""))
+  }
+
+  @Test
+  def downloadMessageShouldDiscardNameWhenNotSuppliedByTheClient(server: 
GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}")
+    .`then`
+      .statusCode(SC_OK)
+      .header("Content-Disposition", emptyOrNullString())
+  }
+
+  @Test
+  def userCanSpecifyNameWhenDownloadingMessage(server: GuiceJamesServer): Unit 
= {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+      .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .queryParam("name", "gabouzomeuh.eml")
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}")
+    .`then`
+      .statusCode(SC_OK)
+      .header("Content-Disposition", 
containsString("filename=\"gabouzomeuh.eml\""))
+  }
+
+  @Test
+  def downloadNotExistingPart(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_333")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+  }
+
+  @Test
+  def downloadInvalidPart(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_invalid")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+  }
+
+  @Test
+  def downloadWithInvalidId(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/invalid")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+      .extract
+      .body
+      .asString
+  }
+
+  @Test
+  def downloadWithNotFoundId(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${randomMessageId.serialize()}")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+  }
+
+  @Test
+  def downloadPartWhenMessageNotFound(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${randomMessageId.serialize()}_3")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+  }
+
+  @Test
+  def downloadPartWhenMultipart(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${randomMessageId.serialize()}_2")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+  }
+
+  @Test
+  def downloadPartWhenTooMuchUnderscore(server: GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+        ClassLoader.getSystemResourceAsStream("eml/multipart_simple.eml")))
+      .getMessageId
+
+    `given`
+        .basePath("")
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+    .when
+      .get(s"/download/$accountId/${messageId.serialize()}_3_3")
+    .`then`
+      .statusCode(SC_NOT_FOUND)
+  }
+}
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/MemoryDownloadTest.java
 
b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryDownloadTest.java
new file mode 100644
index 0000000..6d351ba
--- /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/MemoryDownloadTest.java
@@ -0,0 +1,47 @@
+/****************************************************************
+ * 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.DownloadContract;
+import org.apache.james.mailbox.inmemory.InMemoryMessageId;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryDownloadTest implements DownloadContract {
+    @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);
+    }
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
index 0e3d24c..9876379 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
@@ -27,15 +27,16 @@ import eu.timepit.refined.api.Refined
 import eu.timepit.refined.auto._
 import eu.timepit.refined.numeric.NonNegative
 import eu.timepit.refined.refineV
-import org.apache.james.mime4j.dom.{TextBody => Mime4JTextBody}
 import org.apache.commons.io.IOUtils
 import org.apache.james.jmap.mail.Email.Size
 import org.apache.james.jmap.mail.EmailBodyPart.{MULTIPART_ALTERNATIVE, 
TEXT_HTML, TEXT_PLAIN}
 import org.apache.james.jmap.mail.PartId.PartIdValue
 import org.apache.james.jmap.model.Properties
-import org.apache.james.mailbox.model.{Cid, MessageId}
-import org.apache.james.mime4j.dom.{Entity, Message, Multipart}
-import org.apache.james.mime4j.message.DefaultMessageWriter
+import org.apache.james.mailbox.model.{Cid, MessageId, MessageResult}
+import org.apache.james.mime4j.codec.DecodeMonitor
+import org.apache.james.mime4j.dom.{Entity, Message, Multipart, TextBody => 
Mime4JTextBody}
+import org.apache.james.mime4j.message.{DefaultMessageBuilder, 
DefaultMessageWriter}
+import org.apache.james.mime4j.stream.MimeConfig
 
 import scala.jdk.CollectionConverters._
 import scala.jdk.OptionConverters._
@@ -43,6 +44,12 @@ import scala.util.{Failure, Success, Try}
 
 object PartId {
   type PartIdValue = Int Refined NonNegative
+
+  def parse(string: String): Try[PartId] = Try(string.toInt)
+    .flatMap(i => refineV[NonNegative](i) match {
+      case Left(e) => Failure(new IllegalArgumentException(e))
+      case scala.Right(id) => Success(PartId(id))
+    })
 }
 
 case class PartId(value: PartIdValue) {
@@ -62,6 +69,15 @@ object EmailBodyPart {
   val defaultProperties: Properties = Properties("partId", "blobId", "size", 
"name", "type", "charset", "disposition", "cid", "language", "location")
   val allowedProperties: Properties = defaultProperties ++ 
Properties("subParts", "headers")
 
+  def of(messageId: MessageId, message: MessageResult): Try[EmailBodyPart] = {
+    val defaultMessageBuilder = new DefaultMessageBuilder
+    defaultMessageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE)
+    defaultMessageBuilder.setDecodeMonitor(DecodeMonitor.SILENT)
+
+    val mime4JMessage = 
Try(defaultMessageBuilder.parseMessage(message.getFullContent.getInputStream))
+    mime4JMessage.flatMap(of(messageId, _))
+  }
+
   def of(messageId: MessageId, message: Message): Try[EmailBodyPart] =
     of(messageId, PartId(1), message).map(_._1)
 
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
new file mode 100644
index 0000000..25ed27f
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
@@ -0,0 +1,240 @@
+/** **************************************************************
+ * 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.routes
+
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream, InputStream}
+import java.util.stream
+import java.util.stream.Stream
+
+import com.google.common.base.CharMatcher
+import eu.timepit.refined.numeric.NonNegative
+import eu.timepit.refined.refineV
+import io.netty.buffer.Unpooled
+import io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE
+import io.netty.handler.codec.http.HttpResponseStatus.OK
+import io.netty.handler.codec.http.{HttpMethod, QueryStringDecoder}
+import javax.inject.{Inject, Named}
+import org.apache.http.HttpStatus.SC_NOT_FOUND
+import org.apache.james.jmap.exceptions.UnauthorizedException
+import org.apache.james.jmap.http.Authenticator
+import org.apache.james.jmap.http.rfc8621.InjectionKeys
+import org.apache.james.jmap.json.Serializer
+import org.apache.james.jmap.mail.Email.Size
+import org.apache.james.jmap.mail.{BlobId, EmailBodyPart, PartId}
+import org.apache.james.jmap.routes.DownloadRoutes.{BUFFER_SIZE, LOGGER}
+import org.apache.james.jmap.{Endpoint, JMAPRoute, JMAPRoutes}
+import org.apache.james.mailbox.model.{ContentType, FetchGroup, MessageId, 
MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mime4j.codec.EncoderUtil
+import org.apache.james.mime4j.codec.EncoderUtil.Usage
+import org.apache.james.mime4j.message.DefaultMessageWriter
+import org.apache.james.util.ReactorUtils
+import org.slf4j.{Logger, LoggerFactory}
+import reactor.core.publisher.Mono
+import reactor.core.scala.publisher.SMono
+import reactor.core.scheduler.Schedulers
+import reactor.netty.http.server.{HttpServerRequest, HttpServerResponse}
+
+import scala.compat.java8.FunctionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.{Failure, Success, Try}
+
+object DownloadRoutes {
+  val LOGGER: Logger = LoggerFactory.getLogger(classOf[DownloadRoutes])
+
+  val BUFFER_SIZE: Int = 16 * 1024
+}
+
+trait BlobResolver {
+  def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
Option[SMono[Blob]]
+}
+
+trait Blob {
+  def blobId: BlobId
+  def contentType: ContentType
+  def size: Try[Size]
+  def content: InputStream
+}
+
+case class BlobNotFoundException(blobId: BlobId) extends RuntimeException
+
+case class MessageBlob(blobId: BlobId, message: MessageResult) extends Blob {
+  override def contentType: ContentType = new ContentType("message/rfc822")
+
+  override def size: Try[Size] = refineV[NonNegative](message.getSize) match {
+    case Left(e) => Failure(new IllegalArgumentException(e))
+    case Right(size) => Success(size)
+  }
+
+  override def content: InputStream = message.getFullContent.getInputStream
+}
+
+case class EmailBodyPartBlob(blobId: BlobId, part: EmailBodyPart) extends Blob 
{
+  override def size: Try[Size] = Success(part.size)
+
+  override def contentType: ContentType = new ContentType(part.`type`.value)
+
+  override def content: InputStream = {
+    val writer = new DefaultMessageWriter
+    val outputStream = new ByteArrayOutputStream()
+    writer.writeBody(part.entity.getBody, outputStream)
+    new ByteArrayInputStream(outputStream.toByteArray)
+  }
+}
+
+class MessageBlobResolver @Inject()(val messageIdFactory: MessageId.Factory,
+                          val messageIdManager: MessageIdManager) extends 
BlobResolver {
+  override def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
Option[SMono[Blob]] = {
+    Try(messageIdFactory.fromString(blobId.value.value)) match {
+      case Failure(_) => None
+      case Success(messageId) => Some(SMono.fromPublisher(
+        messageIdManager.getMessagesReactive(List(messageId).asJava, 
FetchGroup.FULL_CONTENT, mailboxSession))
+        .map[Blob](MessageBlob(blobId, _))
+        .switchIfEmpty(SMono.raiseError(BlobNotFoundException(blobId))))
+    }
+  }
+}
+
+class MessagePartBlobResolver @Inject()(val messageIdFactory: 
MessageId.Factory,
+                              val messageIdManager: MessageIdManager) extends 
BlobResolver {
+  private def asMessageAndPartId(blobId: BlobId): Try[(MessageId, PartId)] = {
+    blobId.value.value.split("_").toList match {
+      case List(messageIdString, partIdString) => for {
+        messageId <- Try(messageIdFactory.fromString(messageIdString))
+        partId <- PartId.parse(partIdString)
+      } yield {
+        (messageId, partId)
+      }
+      case _ => Failure(BlobNotFoundException(blobId))
+    }
+  }
+
+  override def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
Option[SMono[Blob]] = {
+    asMessageAndPartId(blobId) match {
+      case Failure(_) => None
+      case Success((messageId, partId)) =>
+        Some(SMono.fromPublisher(
+          messageIdManager.getMessagesReactive(List(messageId).asJava, 
FetchGroup.FULL_CONTENT, mailboxSession))
+          .flatMap(message => SMono.fromTry(EmailBodyPart.of(messageId, 
message)))
+          .flatMap(bodyStructure => SMono.fromTry(bodyStructure.flatten
+              .filter(_.blobId.contains(blobId))
+            .map(Success(_))
+            .headOption
+            .getOrElse(Failure(BlobNotFoundException(blobId)))))
+          .map[Blob](EmailBodyPartBlob(blobId, _))
+          .switchIfEmpty(SMono.raiseError(BlobNotFoundException(blobId))))
+    }
+  }
+}
+
+class BlobResolvers @Inject()(val messageBlobResolver: MessageBlobResolver,
+                    val messagePartBlobResolver: MessagePartBlobResolver) {
+  def resolve(blobId: BlobId, mailboxSession: MailboxSession): SMono[Blob] =
+    messageBlobResolver.resolve(blobId, mailboxSession)
+      .orElse(messagePartBlobResolver.resolve(blobId, mailboxSession))
+      .getOrElse(SMono.raiseError(BlobNotFoundException(blobId)))
+}
+
+class DownloadRoutes @Inject()(@Named(InjectionKeys.RFC_8621) val 
authenticator: Authenticator,
+                     val serializer: Serializer,
+                     val blobResolvers: BlobResolvers) extends JMAPRoutes {
+
+  val accountIdParam: String = "accountId"
+  val blobIdParam: String = "blobId"
+  val nameParam: String = "name"
+  val contentTypeParam: String = "contentType"
+  val downloadUri = s"/download/{$accountIdParam}/{$blobIdParam}"
+
+  override def routes(): stream.Stream[JMAPRoute] = Stream.of(
+    JMAPRoute.builder
+      .endpoint(new Endpoint(HttpMethod.GET, downloadUri))
+      .action(this.get)
+      .corsHeaders,
+    JMAPRoute.builder
+      .endpoint(new Endpoint(HttpMethod.OPTIONS, downloadUri))
+      .action(JMAPRoutes.CORS_CONTROL)
+      .noCorsHeaders)
+
+  private def get(request: HttpServerRequest, response: HttpServerResponse): 
Mono[Void] =
+    SMono(authenticator.authenticate(request))
+      .flatMap((mailboxSession: MailboxSession) =>
+        SMono.fromTry(BlobId.of(request.param(blobIdParam)))
+          .flatMap(blobResolvers.resolve(_, mailboxSession))
+          .flatMap(blob => downloadBlob(
+            optionalName = queryParam(request, nameParam),
+            response = response,
+            blobContentType = queryParam(request, contentTypeParam)
+              .map(ContentType.of)
+              .getOrElse(blob.contentType),
+            blob = blob))
+          .`then`())
+      .onErrorResume {
+        case e: UnauthorizedException => 
SMono.fromPublisher(handleAuthenticationFailure(response, LOGGER, e)).`then`()
+        case _: BlobNotFoundException => 
SMono.fromPublisher(response.status(SC_NOT_FOUND).send).`then`()
+        case e =>
+          LOGGER.error("Unexpected error", e)
+          SMono.fromPublisher(handleInternalError(response, LOGGER, 
e)).`then`()
+      }
+      .subscribeOn(Schedulers.elastic)
+      .asJava()
+      .`then`()
+
+  private def downloadBlob(optionalName: Option[String],
+                           response: HttpServerResponse,
+                           blobContentType: ContentType,
+                           blob: Blob): SMono[Unit] =
+    SMono.fromPublisher(Mono.using(
+      () => blob.content,
+      (stream: InputStream) => addContentDispositionHeader(optionalName)
+        .compose(addContentLengthHeader(blob.size))
+        .apply(response)
+        .header(CONTENT_TYPE, blobContentType.asString)
+        .status(OK)
+        .send(ReactorUtils.toChunks(stream, BUFFER_SIZE)
+          .map(Unpooled.wrappedBuffer(_))
+          .subscribeOn(Schedulers.elastic))
+        .`then`(),
+      asJavaConsumer[InputStream]((stream: InputStream) => stream.close())))
+      .`then`()
+
+  private def addContentDispositionHeader(optionalName: Option[String]): 
HttpServerResponse => HttpServerResponse =
+    resp => optionalName.map(addContentDispositionHeaderRegardingEncoding(_, 
resp))
+      .getOrElse(resp)
+
+  private def addContentLengthHeader(sizeTry: Try[Size]): HttpServerResponse 
=> HttpServerResponse =
+    resp => sizeTry
+      .map(size => resp.header("Content-Length", size.value.toString))
+      .getOrElse(resp)
+
+  private def addContentDispositionHeaderRegardingEncoding(name: String, resp: 
HttpServerResponse): HttpServerResponse =
+    if (CharMatcher.ascii.matchesAllOf(name)) {
+      resp.header("Content-Disposition", "attachment; filename=\"" + name + 
"\"")
+    } else {
+      resp.header("Content-Disposition", "attachment; filename*=\"" + 
EncoderUtil.encodeEncodedWord(name, Usage.TEXT_TOKEN) + "\"")
+    }
+
+  private def queryParam(httpRequest: HttpServerRequest, parameterName: 
String): Option[String] =
+    queryParam(parameterName, httpRequest.uri)
+
+  private def queryParam(parameterName: String, uri: String): Option[String] =
+    Option(new QueryStringDecoder(uri).parameters.get(parameterName))
+      .toList
+      .flatMap(_.asScala)
+      .headOption
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to