chibenwa commented on a change in pull request #351:
URL: https://github.com/apache/james-project/pull/351#discussion_r604786086
##########
File path: mdn/src/main/java/org/apache/james/mdn/MDN.java
##########
@@ -74,10 +83,83 @@ public MDN build() {
}
}
+ public static class MDNParseException extends Exception {
+ public MDNParseException(String message) {
+ super(message);
+ }
+
+ public MDNParseException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ public static class MDNParseContentTypeException extends MDNParseException
{
+ public MDNParseContentTypeException(String message) {
+ super(message);
+ }
+ }
+
+ public static class MDNParseBodyPartInvalidException extends
MDNParseException {
+
+ public MDNParseBodyPartInvalidException(String message) {
+ super(message);
+ }
+ }
+
+
public static Builder builder() {
return new Builder();
}
+ public static MDN parse(Message message) throws MDNParseException {
Review comment:
I do not see tests for `MDN::parse` within the `/mdn` folder. We
definitly need some.
##########
File path:
server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageIdTest.java
##########
@@ -1,107 +1,155 @@
-/****************************************************************
- * 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.mailet;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
-
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-
-import org.apache.james.mailbox.MailboxManager;
-import org.apache.james.mime4j.dom.Message;
-import org.apache.james.mime4j.message.BodyPart;
-import org.apache.james.mime4j.message.BodyPartBuilder;
-import org.apache.james.mime4j.message.MultipartBuilder;
-import org.apache.james.mime4j.message.SingleBodyBuilder;
-import org.apache.james.user.api.UsersRepository;
-import org.junit.Test;
-
-public class ExtractMDNOriginalJMAPMessageIdTest {
-
- @Test
- public void extractReportShouldRejectNonMultipartMessage() throws
IOException {
- ExtractMDNOriginalJMAPMessageId testee = new
ExtractMDNOriginalJMAPMessageId(mock(MailboxManager.class),
mock(UsersRepository.class));
-
- Message message = Message.Builder.of()
- .setBody("content", StandardCharsets.UTF_8)
- .build();
-
- assertThat(testee.extractReport(message)).isEmpty();
- }
-
- @Test
- public void extractReportShouldRejectMultipartWithSinglePart() throws
Exception {
- ExtractMDNOriginalJMAPMessageId testee = new
ExtractMDNOriginalJMAPMessageId(mock(MailboxManager.class),
mock(UsersRepository.class));
-
- Message message = Message.Builder.of()
- .setBody(
- MultipartBuilder.create()
- .setSubType("report")
- .addTextPart("content", StandardCharsets.UTF_8)
- .build())
- .build();
-
- assertThat(testee.extractReport(message)).isEmpty();
- }
-
- @Test
- public void extractReportShouldRejectSecondPartWithBadContentType() throws
IOException {
- ExtractMDNOriginalJMAPMessageId testee = new
ExtractMDNOriginalJMAPMessageId(mock(MailboxManager.class),
mock(UsersRepository.class));
-
- Message message = Message.Builder.of()
- .setBody(MultipartBuilder.create()
- .setSubType("report")
- .addTextPart("first", StandardCharsets.UTF_8)
- .addTextPart("second", StandardCharsets.UTF_8)
- .build())
- .build();
-
- assertThat(testee.extractReport(message)).isEmpty();
- }
-
- @Test
- public void extractReportShouldExtractMDNWhenValidMDN() throws IOException
{
- ExtractMDNOriginalJMAPMessageId testee = new
ExtractMDNOriginalJMAPMessageId(mock(MailboxManager.class),
mock(UsersRepository.class));
-
- BodyPart mdn = BodyPartBuilder
- .create()
- .setBody(SingleBodyBuilder.create()
- .setText(
- "Reporting-UA: linagora.com; Evolution 3.26.5-1+b1 \n" +
- "Final-Recipient: rfc822; [email protected]\n" +
- "Original-Message-ID:
<[email protected]>\n" +
- "Disposition:
manual-action/MDN-sent-manually;displayed\n")
- .buildText())
- .setContentType("message/disposition-notification")
- .build();
-
- Message message = Message.Builder.of()
- .setBody(MultipartBuilder.create("report")
- .addTextPart("first", StandardCharsets.UTF_8)
- .addBodyPart(mdn)
- .build())
- .build();
-
- assertThat(testee.extractReport(message))
- .isNotEmpty()
- .contains(mdn);
- }
+/****************************************************************
+ * 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.mailet;
+
+import org.apache.james.mdn.MDN;
+import org.apache.james.mdn.MDN.MDNParseContentTypeException;
+import org.apache.james.mdn.MDN.MDNParseException;
+import org.apache.james.mdn.MDN.MDNParseBodyPartInvalidException;
+import org.apache.james.mdn.MDNReport;
+import org.apache.james.mdn.action.mode.DispositionActionMode;
+import org.apache.james.mdn.fields.*;
+import org.apache.james.mdn.modifier.DispositionModifier;
+import org.apache.james.mdn.sending.mode.DispositionSendingMode;
+import org.apache.james.mdn.type.DispositionType;
+import org.apache.james.mime4j.dom.Message;
+import org.apache.james.mime4j.message.BodyPart;
+import org.apache.james.mime4j.message.BodyPartBuilder;
+import org.apache.james.mime4j.message.MultipartBuilder;
+import org.apache.james.mime4j.message.SingleBodyBuilder;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class ExtractMDNOriginalJMAPMessageIdTest {
+
+ @Test
+ public void extractReportShouldRejectNonMultipartMessage() throws
Exception {
+ Message message = Message.Builder.of()
+ .setBody("content", StandardCharsets.UTF_8)
+ .build();
+ assertThatThrownBy(() -> MDN.parse(message))
+ .isInstanceOf(MDNParseContentTypeException.class)
+ .hasMessage("MDN Message must be multipart");
+
+ }
+
+ @Test
+ public void extractReportShouldRejectMultipartWithSinglePart() throws
Exception {
+ Message message = Message.Builder.of()
+ .setBody(
+ MultipartBuilder.create()
+ .setSubType("report")
+ .addTextPart("content", StandardCharsets.UTF_8)
+ .build())
+ .build();
+ assertThatThrownBy(() -> MDN.parse(message))
+ .isInstanceOf(MDNParseBodyPartInvalidException.class)
+ .hasMessage("MDN Message must contain at least two parts");
+ }
+
+ @Test
+ public void extractReportShouldRejectSecondPartWithBadContentType() throws
Exception {
+ Message message = Message.Builder.of()
+ .setBody(MultipartBuilder.create()
+ .setSubType("report")
+ .addTextPart("first", StandardCharsets.UTF_8)
+ .addTextPart("second", StandardCharsets.UTF_8)
+ .build())
+ .build();
+ assertThatThrownBy(() -> MDN.parse(message))
+ .isInstanceOf(MDNParseException.class)
+ .hasMessage("MDN can not extract");
+ }
+
+ @Test
+ public void extractReportShouldExtractMDNWhenValidMDN() throws Exception {
+ BodyPart mdnBodyPart = BodyPartBuilder
+ .create()
+ .setBody(SingleBodyBuilder.create()
+ .setText(
+ "Reporting-UA: UA_name; UA_product\r\n" +
+ "MDN-Gateway: rfc822; apache.org\r\n" +
+ "Original-Recipient: rfc822;
originalRecipient\r\n" +
+ "Final-Recipient: rfc822;
final_recipient\r\n" +
+ "Original-Message-ID:
<[email protected]>\r\n" +
+ "Disposition:
automatic-action/MDN-sent-automatically;processed/error,failed\r\n" +
+ "Error: Message1\r\n" +
+ "Error: Message2\r\n" +
+ "X-OPENPAAS-IP: 177.177.177.77\r\n" +
+ "X-OPENPAAS-PORT: 8000\r\n" +
+ ""
+
.replace(System.lineSeparator(), "\r\n").strip()
+ )
+ .buildText())
+ .setContentType("message/disposition-notification")
+ .build();
+
+ Message message = Message.Builder.of()
+ .setBody(MultipartBuilder.create("report")
+ .addTextPart("first", StandardCharsets.UTF_8)
+ .addBodyPart(mdnBodyPart)
+ .build())
+ .build();
+ var mdnActual = MDN.parse(message);
Review comment:
Tests about MDN::parse should be located in `/mdn` `MDNTest`
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @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 mdnParseHasValidBodyFormatShouldSucceed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
Review comment:
Debug statement spotted
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
Review comment:
Why not reuse `Id.IdConstraint`?
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
+ .toScala,
+ finalRecipient =
FinalRecipientField(s"${report.getFinalRecipientField.getAddressType.getType};
${report.getFinalRecipientField.getFinalRecipient.formatted()}"),
Review comment:
Why not `finalRecipient =
FinalRecipientField(eport.getFinalRecipientField.fieldValue)` so that we don't
rewrite MDN logic here?
Then we modify `FinalRecipient` (java class):
```
@Override
public String formattedValue() {
return FIELD_NAME + ": " + fieldValue();
}
private String fieldValue() {
return addressType.getType() + "; " + finalRecipient.formatted();
}
```
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
+ .toScala,
+ finalRecipient =
FinalRecipientField(s"${report.getFinalRecipientField.getAddressType.getType};
${report.getFinalRecipientField.getFinalRecipient.formatted()}"),
+ originalMessageId = report.getOriginalMessageIdField
+ .map(originalMessageId =>
OriginalMessageIdField(s"${originalMessageId.getOriginalMessageId}"))
+ .toScala,
+ disposition = MDNDisposition.convertFromJava(report.getDispositionField),
Review comment:
MDNDisposition.fromJava(
looks nicer.
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
+ .toScala,
+ finalRecipient =
FinalRecipientField(s"${report.getFinalRecipientField.getAddressType.getType};
${report.getFinalRecipientField.getFinalRecipient.formatted()}"),
+ originalMessageId = report.getOriginalMessageIdField
+ .map(originalMessageId =>
OriginalMessageIdField(s"${originalMessageId.getOriginalMessageId}"))
+ .toScala,
+ disposition = MDNDisposition.convertFromJava(report.getDispositionField),
+ error = Option(report.getErrorFields.asScala
+ .map(error => ErrorField(error.getText.formatted()))
+ .toSeq)
+ .filter(error => error.nonEmpty),
+ extension = Option(report.getExtensionFields.asScala
+ .map(extensionField => (extensionField.getFieldName,
extensionField.getRawValue))
+ .toMap).filter(_.nonEmpty)
+ .map(extension => ExtensionField(extension))
+ )
Review comment:
Lonely bracket ;-)
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 *
+ * *
Review comment:
Warning indentation levels within the license....
##########
File path: mdn/src/main/java/org/apache/james/mdn/MDN.java
##########
@@ -155,7 +237,7 @@ public final boolean equals(Object o) {
MDN mdn = (MDN) o;
return Objects.equals(this.humanReadableText,
mdn.humanReadableText)
- && Objects.equals(this.report, mdn.report);
+ && Objects.equals(this.report, mdn.report);
Review comment:
Can we revert indentation changes?
Can you set up your ide to use 4 space indentation?
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @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 mdnParseHasValidBodyFormatShouldSucceed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response)
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [
+ | [ "MDN/parse", {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: [email protected]\\r\\nSubject:
test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina
Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; ",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "MDN-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822; [email protected]",
+ | "originalMessageId":
"<[email protected]>",
+ | "error": [
+ | "Message1",
+ | "Message2"
+ | ],
+ | "extension": {
Review comment:
`extension` should be `extensionFields`!
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 *
+ * *
Review comment:
Oups
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @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 mdnParseHasValidBodyFormatShouldSucceed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response)
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [
+ | [ "MDN/parse", {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: [email protected]\\r\\nSubject:
test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina
Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; ",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "MDN-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822; [email protected]",
+ | "originalMessageId":
"<[email protected]>",
Review comment:
What if the report have a `originalRecipient` field?
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala
##########
@@ -33,6 +34,7 @@ object BlobId {
}
def of(messageId: MessageId): Try[BlobId] = of(messageId.serialize())
def of(messageId: MessageId, partId: PartId): Try[BlobId] =
of(s"${messageId.serialize()}_${partId.serialize}")
+ def toJava(blobId: BlobId): JavaBlobId =
JavaBlobId.fromString(blobId.value.value)
Review comment:
We can move this to the BlobId class.
I prefer seeing `blobId.asJava` written rather than `BlobId.toJava(blobId)`
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
Review comment:
We do not need that block of code
`def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] =
blobIds.map(unparsed => BlobId.of(unparsed.value))`
is better
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @BeforeEach
Review comment:
Missing line break
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @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 mdnParseHasValidBodyFormatShouldSucceed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response)
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [
+ | [ "MDN/parse", {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: [email protected]\\r\\nSubject:
test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina
Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; ",
Review comment:
Should this be `OpenPaaS Unified Inbox` only?
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
Review comment:
`2` is a biiit defensive. How about `16` ?
Also we generaly extract this to a constant (val, outside of this def) so
that we can give it an appropriate name (maxBlobCount)?
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @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 mdnParseHasValidBodyFormatShouldSucceed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response)
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [
+ | [ "MDN/parse", {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: [email protected]\\r\\nSubject:
test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina
Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; ",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "MDN-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822; [email protected]",
+ | "originalMessageId":
"<[email protected]>",
+ | "error": [
+ | "Message1",
+ | "Message2"
+ | ],
+ | "extension": {
+ | "X-OPENPAAS-IP" : " 177.177.177.77",
+ | "X-OPENPAAS-PORT" : " 8000"
+ | }
+ | }
+ | }
+ | }, "c1" ]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def mdnParseShouldFailWhenWrongAccountId(): Unit = {
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId": "unknownAccountId",
+ | "blobIds": [ "0f9f65ab-dc7b-4146-850f-6e4881093965" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [[
+ | "error",
+ | {
+ | "type": "accountNotFound"
+ | },
+ | "c1"
+ | ]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def mdnParseShouldFailWhenNumberOfBlobIdsTooLarge(): Unit = {
+ val blogIds =
LazyList.continually(randomMessageId.serialize()).take(201).toArray;
+ val blogIdsJson = Json.stringify(Json.arr(blogIds)).replace("[[",
"[").replace("]]", "]");
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": ${blogIdsJson}
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response)
+ .whenIgnoringPaths("methodResponses[0][1].description")
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [[
+ | "error",
+ | {
+ | "type": "requestTooLarge",
+ | "description": "The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def parseShouldReturnNotParseableWhenNotAnMDN(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.builder()
+ .build(Message.Builder
+ .of
+ .setSubject("Subject MDN")
+ .setSender(ANDRE.asString())
+ .setFrom(ANDRE.asString())
+ .setBody(MultipartBuilder.create("report")
+ .addTextPart("This is body of text part", StandardCharsets.UTF_8)
+ .build)
+ .build))
+ .getMessageId
+
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "notParsable": ["${messageId.serialize()}"]
+ | },
+ | "c1"
+ | ]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def parseShouldReturnNotFoundWhenBlobDoNotExist(): Unit = {
+ val blobIdShouldNotFound = randomMessageId.serialize()
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "$blobIdShouldNotFound" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "notFound": ["$blobIdShouldNotFound"]
+ | },
+ | "c1"
+ | ]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def parseShouldReturnNotFoundWhenBadBlobId(): Unit = {
+ val blobIdShouldNotFound = randomMessageId.serialize()
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "invalid" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "notFound": ["invalid"]
+ | },
+ | "c1"
+ | ]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def parseAndNotFoundAndNotParsableCanBeMixed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val blobIdParsable: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val blobIdNotParsable: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.builder()
+ .build(Message.Builder
+ .of
+ .setSubject("Subject MDN")
+ .setSender(ANDRE.asString())
+ .setFrom(ANDRE.asString())
+ .setBody(MultipartBuilder.create("report")
+ .addTextPart("This is body of text part", StandardCharsets.UTF_8)
+ .build)
+ .build))
+ .getMessageId
+ val blobIdNotFound = randomMessageId
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${blobIdParsable.serialize()}",
"${blobIdNotParsable.serialize()}", "${blobIdNotFound.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ awaitAtMostTenSeconds.untilAsserted{
+ () => {
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+ assertThatJson(response).isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "notFound": ["${blobIdNotFound.serialize()}"],
+ | "notParsable": ["${blobIdNotParsable.serialize()}"],
+ | "parsed": {
+ | "${blobIdParsable.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To:
[email protected]\\r\\nSubject: test\\r\\nMessage was displayed on Tue Mar
30 2021 10:31:50 GMT+0700 (Indochina Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; ",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "MDN-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822;
[email protected]",
+ | "originalMessageId":
"<[email protected]>",
+ | "error": [
+ | "Message1",
+ | "Message2"
+ | ],
+ | "extension": {
+ | "X-OPENPAAS-IP" : " 177.177.177.77",
+ | "X-OPENPAAS-PORT" : " 8000"
+ | }
+ | }
+ | }
+ | },
+ | "c1"
+ | ]]
+ |}""".stripMargin)
+ }
+ }
+ }
+
+
+ @Test
+ def mdnParseShouldReturnUnknownMethodWhenMissingOneCapability(): Unit = {
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "123" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .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:mdn"
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def mdnParseShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+ val request =
+ s"""{
+ | "using": [],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "123" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .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:mdn, urn:ietf:params:jmap:mail"
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ }
+}
Review comment:
I miss tests:
- Demonstrating that we succeed to parse a MDN with all properties
- Demonstrate that we succeed to parse a MDN with only minimal fields
- A test with a third mime part and `includeOriginalMessage=true`
- Can we have a test parsing (say 5 MDN ?)
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
Review comment:
Should we append `;` if `userAgent.getUserAgentProduct` is empty?
The grammar defined in https://tools.ietf.org/html/rfc8098#section-3.2
(section 3.2.1) states:
```
reporting-ua-field = "Reporting-UA" ":" OWS ua-name OWS
[ ";" OWS ua-product OWS ]
```
We might need to fix `org.apache.james.mdn.fields.ReportingUserAgent` as
well.
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
Review comment:
p1 p2 are not acceptable variable names.
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
Review comment:
I am not sure we have any benefits from extending anyval with complex
object like Map. Can we remove this one?
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
Review comment:
fromJava
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
Review comment:
We need a test with a message without subject: it will fail.
What you are looking for might be `subject =
Option(message.getSubject).map(SubjectField(_))`
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
##########
@@ -0,0 +1,114 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier,
JMAP_MAIL, JMAP_MDN}
+import org.apache.james.jmap.core.Invocation._
+import org.apache.james.jmap.core.{AccountId, ErrorCode, Invocation, Session}
+import org.apache.james.jmap.json.{MDNParseSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNParse.UnparsedBlobId
+import org.apache.james.jmap.mail.{BlobId, MDNParseRequest, MDNParseResponse,
MDNParseResults, MDNParsed}
+import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers,
SessionSupplier}
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.mdn.MDN
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.server.core.MimeMessageInputStream
+import org.apache.james.util.MimeMessageUtil
+import play.api.libs.json.{JsError, JsObject, JsSuccess}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import java.io.InputStream
+import javax.inject.Inject
+import scala.util.{Failure, Success, Try}
+
+case class RequestTooLargeException(description: String) extends Exception
+
+class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
+ val metricFactory: MetricFactory,
+ val sessionSupplier: SessionSupplier) extends
MethodRequiringAccountId[MDNParseRequest] {
+ override val methodName: MethodName = MethodName("MDN/parse")
+ override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MDN,
JMAP_MAIL)
Review comment:
We should require CORE too.
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
+ .toScala,
+ finalRecipient =
FinalRecipientField(s"${report.getFinalRecipientField.getAddressType.getType};
${report.getFinalRecipientField.getFinalRecipient.formatted()}"),
+ originalMessageId = report.getOriginalMessageIdField
+ .map(originalMessageId =>
OriginalMessageIdField(s"${originalMessageId.getOriginalMessageId}"))
Review comment:
`.map(originalMessageId =>
OriginalMessageIdField(originalMessageId.getOriginalMessageId))`
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
+ .toScala,
+ finalRecipient =
FinalRecipientField(s"${report.getFinalRecipientField.getAddressType.getType};
${report.getFinalRecipientField.getFinalRecipient.formatted()}"),
+ originalMessageId = report.getOriginalMessageIdField
+ .map(originalMessageId =>
OriginalMessageIdField(s"${originalMessageId.getOriginalMessageId}"))
+ .toScala,
+ disposition = MDNDisposition.convertFromJava(report.getDispositionField),
+ error = Option(report.getErrorFields.asScala
+ .map(error => ErrorField(error.getText.formatted()))
+ .toSeq)
+ .filter(error => error.nonEmpty),
+ extension = Option(report.getExtensionFields.asScala
+ .map(extensionField => (extensionField.getFieldName,
extensionField.getRawValue))
+ .toMap).filter(_.nonEmpty)
+ .map(extension => ExtensionField(extension))
+ )
+ }
+}
+
+case class MDNParsed(forEmailId: Option[ForEmailIdField],
+ subject: Option[SubjectField],
+ textBody: Option[TextBodyField],
+ reportingUA: Option[ReportUAField],
+ finalRecipient: FinalRecipientField,
+ originalMessageId: Option[OriginalMessageIdField],
+ disposition: MDNDisposition,
+ error: Option[Seq[ErrorField]],
+ extension: Option[ExtensionField])
+
+object MDNParseResults {
+ def notFound(blobId: UnparsedBlobId): MDNParseResults =
MDNParseResults(None, Some(MDNNotFound(Set(blobId))), None)
+
+ def notFound(blobId: BlobId): MDNParseResults = MDNParseResults(None,
Some(MDNNotFound(Set(blobId.value))), None)
+
+ def notParse(blobId: UnparsedBlobId): MDNParseResults =
MDNParseResults(None, None, Some(MDNNotParsable(Set(blobId))))
+
+ def notParse(blobId: BlobId): MDNParseResults = MDNParseResults(None, None,
Some(MDNNotParsable(Set(blobId.value))))
+
+ def parse(blobId: BlobId, mdnParsed: MDNParsed): MDNParseResults =
MDNParseResults(Some(Map(blobId -> mdnParsed)), None, None)
+
+ def empty(): MDNParseResults = MDNParseResults(None, None, None)
+
+ def merge(response1: MDNParseResults, response2: MDNParseResults):
MDNParseResults = {
+ MDNParseResults(
+ parsed = (response1.parsed ++ response2.parsed).reduceOption((p1, p2) =>
p1 ++ p2),
+ notFound = (response1.notFound ++ response2.notFound).reduceOption((p1,
p2) => p1.merge(p2)),
+ notParsable = (response1.notParsable ++
response2.notParsable).reduceOption((p1, p2) => p1.merge(p2)))
+ }
+}
+
+case class MDNParseResults(parsed: Option[Map[BlobId, MDNParsed]],
+ notFound: Option[MDNNotFound],
+ notParsable: Option[MDNNotParsable]) {
+ def asResponse(accountId: AccountId): MDNParseResponse = MDNParseResponse(
+ accountId,
+ parsed,
+ notFound,
+ notParsable
+ )
Review comment:
I am opinionated on not having only brackets alone in a line.. I prefer:
```
def asResponse(accountId: AccountId): MDNParseResponse = MDNParseResponse(
accountId,
parsed,
notFound,
notParsable)
```
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
##########
@@ -0,0 +1,114 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier,
JMAP_MAIL, JMAP_MDN}
+import org.apache.james.jmap.core.Invocation._
+import org.apache.james.jmap.core.{AccountId, ErrorCode, Invocation, Session}
+import org.apache.james.jmap.json.{MDNParseSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNParse.UnparsedBlobId
+import org.apache.james.jmap.mail.{BlobId, MDNParseRequest, MDNParseResponse,
MDNParseResults, MDNParsed}
+import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers,
SessionSupplier}
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.mdn.MDN
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.server.core.MimeMessageInputStream
+import org.apache.james.util.MimeMessageUtil
+import play.api.libs.json.{JsError, JsObject, JsSuccess}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import java.io.InputStream
+import javax.inject.Inject
+import scala.util.{Failure, Success, Try}
+
+case class RequestTooLargeException(description: String) extends Exception
+
+class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
+ val metricFactory: MetricFactory,
+ val sessionSupplier: SessionSupplier) extends
MethodRequiringAccountId[MDNParseRequest] {
+ override val methodName: MethodName = MethodName("MDN/parse")
+ override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MDN,
JMAP_MAIL)
+
+ def doProcess(capabilities: Set[CapabilityIdentifier],
+ invocation: InvocationWithContext,
+ mailboxSession: MailboxSession,
+ request: MDNParseRequest): SMono[InvocationWithContext] = {
+ computeResponseInvocation(request, invocation.invocation, mailboxSession)
+ .map(InvocationWithContext(_, invocation.processingContext))
+ }
+
+ override def getRequest(mailboxSession: MailboxSession, invocation:
Invocation): Either[Exception, MDNParseRequest] =
+ MDNParseSerializer.deserializeMDNParseRequest(invocation.arguments.value)
match {
+ case JsSuccess(emailGetRequest, _) =>
validateRequestParameters(emailGetRequest)
+ case errors: JsError => Left(new
IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
Review comment:
```suggestion
case errors: JsError => Left(new
IllegalArgumentException(Json.stringify(ResponseSerializer.serialize(errors))))
```
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
##########
@@ -0,0 +1,114 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier,
JMAP_MAIL, JMAP_MDN}
+import org.apache.james.jmap.core.Invocation._
+import org.apache.james.jmap.core.{AccountId, ErrorCode, Invocation, Session}
+import org.apache.james.jmap.json.{MDNParseSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNParse.UnparsedBlobId
+import org.apache.james.jmap.mail.{BlobId, MDNParseRequest, MDNParseResponse,
MDNParseResults, MDNParsed}
+import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers,
SessionSupplier}
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.mdn.MDN
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.server.core.MimeMessageInputStream
+import org.apache.james.util.MimeMessageUtil
+import play.api.libs.json.{JsError, JsObject, JsSuccess}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import java.io.InputStream
+import javax.inject.Inject
+import scala.util.{Failure, Success, Try}
+
+case class RequestTooLargeException(description: String) extends Exception
+
+class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
+ val metricFactory: MetricFactory,
+ val sessionSupplier: SessionSupplier) extends
MethodRequiringAccountId[MDNParseRequest] {
+ override val methodName: MethodName = MethodName("MDN/parse")
+ override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MDN,
JMAP_MAIL)
+
+ def doProcess(capabilities: Set[CapabilityIdentifier],
+ invocation: InvocationWithContext,
+ mailboxSession: MailboxSession,
+ request: MDNParseRequest): SMono[InvocationWithContext] = {
+ computeResponseInvocation(request, invocation.invocation, mailboxSession)
+ .map(InvocationWithContext(_, invocation.processingContext))
+ }
+
+ override def getRequest(mailboxSession: MailboxSession, invocation:
Invocation): Either[Exception, MDNParseRequest] =
+ MDNParseSerializer.deserializeMDNParseRequest(invocation.arguments.value)
match {
+ case JsSuccess(emailGetRequest, _) =>
validateRequestParameters(emailGetRequest)
+ case errors: JsError => Left(new
IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+ }
+
+ private def validateRequestParameters(request: MDNParseRequest):
Either[RequestTooLargeException, MDNParseRequest] =
+ if (request.blobIds.value.length > 200) {
+ Left(RequestTooLargeException("The number of ids requested by the client
exceeds the maximum number the server is willing to process in a single method
call"))
+ } else {
+ Right(request)
+ }
+
+ def computeResponseInvocation(request: MDNParseRequest,
+ invocation: Invocation,
+ mailboxSession: MailboxSession):
SMono[Invocation] =
+ computeResponse(request, mailboxSession)
+ .map(res => Invocation(
+ methodName,
+ Arguments(MDNParseSerializer.serialize(res).as[JsObject]),
+ invocation.methodCallId))
+
+ private def computeResponse(request: MDNParseRequest,
+ mailboxSession: MailboxSession):
SMono[MDNParseResponse] = {
+ val validations: Seq[Either[MDNParseResults, BlobId]] =
request.blobIds.value
+ .map(id => BlobId.of(id)
+ .toEither
+ .left
+ .map(_ => MDNParseResults.notFound(id)))
+ val parsedIds: Seq[BlobId] = validations.flatMap(_.toOption)
+ val invalid: Seq[MDNParseResults] =
validations.map(_.left).flatMap(_.toOption)
+
+ val parsed: SFlux[MDNParseResults] = SFlux.fromIterable(parsedIds)
+ .flatMap(blobId => blobResolvers.resolve(blobId, mailboxSession))
+ .map(blob => parse(blob.blobId, blob.content))
+
+ SFlux.merge(Seq(parsed, SFlux.fromIterable(invalid)))
+ .onErrorRecover {
+ case e: BlobNotFoundException => MDNParseResults.notFound(e.blobId)
+ }
+ .reduce(MDNParseResults.empty())(MDNParseResults.merge)
+ .map(result => result.asResponse(request.accountId))
Review comment:
```suggestion
.map(_.asResponse(request.accountId))
```
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
##########
@@ -0,0 +1,173 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.api.Refined
+import eu.timepit.refined.collection.NonEmpty
+import org.apache.james.jmap.core.AccountId
+import org.apache.james.jmap.mail.MDNParse._
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mdn.MDN
+import org.apache.james.mdn.fields.{Disposition => JavaDisposition}
+import org.apache.james.mime4j.dom.Message
+
+import scala.jdk.OptionConverters._
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+object MDNParse {
+ type UnparsedBlobIdConstraint = NonEmpty
+ type UnparsedBlobId = String Refined UnparsedBlobIdConstraint
+}
+
+object BlobIds {
+ def parse(blobIds: Seq[UnparsedBlobId]): Seq[Try[BlobId]] = {
+ blobIds.map(unparsed => BlobId.of(unparsed.value))
+ }
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ def validate: Either[IllegalArgumentException, MDNParseRequest] = {
+ if (blobIds.value.length > 2) {
+ Left(new IllegalArgumentException(s"The number of ids requested by the
client exceeds the maximum number the server is willing to process in a single
method call"))
+ } else {
+ scala.Right(this)
+ }
+ }
+}
+
+object MDNNotFound {
+ def empty(): MDNNotFound = MDNNotFound(Set())
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++
other.value)
+}
+
+object MDNNotParsable {
+ def empty(): MDNNotParsable = MDNNotParsable(Set())
+
+ def merge(p1: MDNNotParsable, p2: MDNNotParsable): MDNNotParsable =
MDNNotParsable(p1.value ++ p2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value
++ other.value)
+}
+
+case class MDNParseFailure(value: UnparsedBlobId)
+
+object MDNDisposition {
+ def convertFromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue,
+ `type` = javaDisposition.getType.getValue)
+}
+
+case class MDNDisposition(actionMode: String,
+ sendingMode: String,
+ `type`: String)
+
+case class ForEmailIdField(value: String) extends AnyVal
+case class SubjectField(value: String) extends AnyVal
+case class TextBodyField(value: String) extends AnyVal
+case class ReportUAField(value: String) extends AnyVal
+case class FinalRecipientField(value: String) extends AnyVal
+case class OriginalMessageIdField(value: String) extends AnyVal
+case class ErrorField(value: String) extends AnyVal
+case class ExtensionField(value: Map[String, String]) extends AnyVal
+
+
+object MDNParsed {
+ def convertFromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport;
+ MDNParsed(
+ forEmailId = None,
+ subject = Option(SubjectField(message.getSubject)),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(s"${userAgent.getUserAgentName};
${userAgent.getUserAgentProduct.orElse("")}"))
+ .toScala,
+ finalRecipient =
FinalRecipientField(s"${report.getFinalRecipientField.getAddressType.getType};
${report.getFinalRecipientField.getFinalRecipient.formatted()}"),
+ originalMessageId = report.getOriginalMessageIdField
+ .map(originalMessageId =>
OriginalMessageIdField(s"${originalMessageId.getOriginalMessageId}"))
+ .toScala,
+ disposition = MDNDisposition.convertFromJava(report.getDispositionField),
+ error = Option(report.getErrorFields.asScala
+ .map(error => ErrorField(error.getText.formatted()))
+ .toSeq)
+ .filter(error => error.nonEmpty),
+ extension = Option(report.getExtensionFields.asScala
+ .map(extensionField => (extensionField.getFieldName,
extensionField.getRawValue))
+ .toMap).filter(_.nonEmpty)
+ .map(extension => ExtensionField(extension))
+ )
+ }
+}
+
+case class MDNParsed(forEmailId: Option[ForEmailIdField],
+ subject: Option[SubjectField],
+ textBody: Option[TextBodyField],
+ reportingUA: Option[ReportUAField],
+ finalRecipient: FinalRecipientField,
+ originalMessageId: Option[OriginalMessageIdField],
+ disposition: MDNDisposition,
+ error: Option[Seq[ErrorField]],
+ extension: Option[ExtensionField])
+
+object MDNParseResults {
+ def notFound(blobId: UnparsedBlobId): MDNParseResults =
MDNParseResults(None, Some(MDNNotFound(Set(blobId))), None)
+
+ def notFound(blobId: BlobId): MDNParseResults = MDNParseResults(None,
Some(MDNNotFound(Set(blobId.value))), None)
+
+ def notParse(blobId: UnparsedBlobId): MDNParseResults =
MDNParseResults(None, None, Some(MDNNotParsable(Set(blobId))))
+
+ def notParse(blobId: BlobId): MDNParseResults = MDNParseResults(None, None,
Some(MDNNotParsable(Set(blobId.value))))
+
+ def parse(blobId: BlobId, mdnParsed: MDNParsed): MDNParseResults =
MDNParseResults(Some(Map(blobId -> mdnParsed)), None, None)
+
+ def empty(): MDNParseResults = MDNParseResults(None, None, None)
+
+ def merge(response1: MDNParseResults, response2: MDNParseResults):
MDNParseResults = {
Review comment:
Code block not needed
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
##########
@@ -0,0 +1,114 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier,
JMAP_MAIL, JMAP_MDN}
+import org.apache.james.jmap.core.Invocation._
+import org.apache.james.jmap.core.{AccountId, ErrorCode, Invocation, Session}
+import org.apache.james.jmap.json.{MDNParseSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNParse.UnparsedBlobId
+import org.apache.james.jmap.mail.{BlobId, MDNParseRequest, MDNParseResponse,
MDNParseResults, MDNParsed}
+import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers,
SessionSupplier}
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.mdn.MDN
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.server.core.MimeMessageInputStream
+import org.apache.james.util.MimeMessageUtil
+import play.api.libs.json.{JsError, JsObject, JsSuccess}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import java.io.InputStream
+import javax.inject.Inject
+import scala.util.{Failure, Success, Try}
+
+case class RequestTooLargeException(description: String) extends Exception
+
+class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
+ val metricFactory: MetricFactory,
+ val sessionSupplier: SessionSupplier) extends
MethodRequiringAccountId[MDNParseRequest] {
+ override val methodName: MethodName = MethodName("MDN/parse")
+ override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MDN,
JMAP_MAIL)
+
+ def doProcess(capabilities: Set[CapabilityIdentifier],
+ invocation: InvocationWithContext,
+ mailboxSession: MailboxSession,
+ request: MDNParseRequest): SMono[InvocationWithContext] = {
Review comment:
This code block is not needed
##########
File path:
server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
##########
@@ -0,0 +1,538 @@
+/** **************************************************************
+ * 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 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_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+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.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.message.MultipartBuilder
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.util.ClassLoaderUtils
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.junit.jupiter.api.{BeforeEach, Test}
+import play.api.libs.json._
+
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.TimeUnit
+
+trait MDNParseMethodContract {
+ private lazy val slowPacedPollInterval = ONE_HUNDRED_MILLISECONDS
+ private lazy val calmlyAwait = Awaitility.`with`
+ .pollInterval(slowPacedPollInterval)
+ .and.`with`.pollDelay(slowPacedPollInterval)
+ .await
+
+ private lazy val awaitAtMostTenSeconds = calmlyAwait.atMost(5,
TimeUnit.SECONDS)
+ @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 mdnParseHasValidBodyFormatShouldSucceed(guiceJamesServer:
GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl =
guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ val response = `given`
+ .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+ .body(request)
+ .when
+ .post.prettyPeek()
+ .`then`
+ .statusCode(SC_OK)
+ .contentType(JSON)
+ .extract
+ .body
+ .asString
+
+ assertThatJson(response)
+ .isEqualTo(
+ s"""{
+ | "sessionState": "${SESSION_STATE.value}",
+ | "methodResponses": [
+ | [ "MDN/parse", {
+ | "accountId":
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: [email protected]\\r\\nSubject:
test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina
Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; ",
Review comment:
We are missing `includeOriginalMessage` field, which should be false.
##########
File path:
server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
##########
@@ -0,0 +1,114 @@
+/** **************************************************************
+ * 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 eu.timepit.refined.auto._
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier,
JMAP_MAIL, JMAP_MDN}
+import org.apache.james.jmap.core.Invocation._
+import org.apache.james.jmap.core.{AccountId, ErrorCode, Invocation, Session}
+import org.apache.james.jmap.json.{MDNParseSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNParse.UnparsedBlobId
+import org.apache.james.jmap.mail.{BlobId, MDNParseRequest, MDNParseResponse,
MDNParseResults, MDNParsed}
+import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers,
SessionSupplier}
+import org.apache.james.mailbox.MailboxSession
+import org.apache.james.mdn.MDN
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.server.core.MimeMessageInputStream
+import org.apache.james.util.MimeMessageUtil
+import play.api.libs.json.{JsError, JsObject, JsSuccess}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import java.io.InputStream
+import javax.inject.Inject
+import scala.util.{Failure, Success, Try}
+
+case class RequestTooLargeException(description: String) extends Exception
+
+class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
+ val metricFactory: MetricFactory,
+ val sessionSupplier: SessionSupplier) extends
MethodRequiringAccountId[MDNParseRequest] {
+ override val methodName: MethodName = MethodName("MDN/parse")
+ override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MDN,
JMAP_MAIL)
+
+ def doProcess(capabilities: Set[CapabilityIdentifier],
+ invocation: InvocationWithContext,
+ mailboxSession: MailboxSession,
+ request: MDNParseRequest): SMono[InvocationWithContext] = {
+ computeResponseInvocation(request, invocation.invocation, mailboxSession)
+ .map(InvocationWithContext(_, invocation.processingContext))
+ }
+
+ override def getRequest(mailboxSession: MailboxSession, invocation:
Invocation): Either[Exception, MDNParseRequest] =
+ MDNParseSerializer.deserializeMDNParseRequest(invocation.arguments.value)
match {
+ case JsSuccess(emailGetRequest, _) =>
validateRequestParameters(emailGetRequest)
+ case errors: JsError => Left(new
IllegalArgumentException(ResponseSerializer.serialize(errors).toString))
+ }
+
+ private def validateRequestParameters(request: MDNParseRequest):
Either[RequestTooLargeException, MDNParseRequest] =
+ if (request.blobIds.value.length > 200) {
+ Left(RequestTooLargeException("The number of ids requested by the client
exceeds the maximum number the server is willing to process in a single method
call"))
+ } else {
+ Right(request)
+ }
+
+ def computeResponseInvocation(request: MDNParseRequest,
+ invocation: Invocation,
+ mailboxSession: MailboxSession):
SMono[Invocation] =
+ computeResponse(request, mailboxSession)
+ .map(res => Invocation(
+ methodName,
+ Arguments(MDNParseSerializer.serialize(res).as[JsObject]),
+ invocation.methodCallId))
+
+ private def computeResponse(request: MDNParseRequest,
+ mailboxSession: MailboxSession):
SMono[MDNParseResponse] = {
+ val validations: Seq[Either[MDNParseResults, BlobId]] =
request.blobIds.value
+ .map(id => BlobId.of(id)
+ .toEither
+ .left
+ .map(_ => MDNParseResults.notFound(id)))
+ val parsedIds: Seq[BlobId] = validations.flatMap(_.toOption)
+ val invalid: Seq[MDNParseResults] =
validations.map(_.left).flatMap(_.toOption)
+
+ val parsed: SFlux[MDNParseResults] = SFlux.fromIterable(parsedIds)
+ .flatMap(blobId => blobResolvers.resolve(blobId, mailboxSession))
Review comment:
```suggestion
.flatMap(blobResolvers.resolve(_, mailboxSession))
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]