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]
