You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2021/04/07 00:53:56 UTC
[james-project] branch master updated: JAMES-3520 Implement JMAP
MDN/parse (RFC-9007) (#351)
This is an automated email from the ASF dual-hosted git repository.
btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/master by this push:
new b395178 JAMES-3520 Implement JMAP MDN/parse (RFC-9007) (#351)
b395178 is described below
commit b395178ce585cb7db04e31a072538068b3dbabfd
Author: vttran <vt...@linagora.com>
AuthorDate: Wed Apr 7 07:53:46 2021 +0700
JAMES-3520 Implement JMAP MDN/parse (RFC-9007) (#351)
---
mdn/src/main/java/org/apache/james/mdn/MDN.java | 123 +++-
.../main/java/org/apache/james/mdn/MDNReport.java | 4 +
.../apache/james/mdn/fields/ExtensionField.java | 8 +
.../apache/james/mdn/fields/FinalRecipient.java | 6 +-
.../apache/james/mdn/fields/OriginalRecipient.java | 6 +-
.../james/mdn/fields/ReportingUserAgent.java | 8 +-
.../apache/james/mdn/MDNReportFormattingTest.java | 2 +-
.../test/java/org/apache/james/mdn/MDNTest.java | 204 ++++++
.../james/mdn/fields/FinalRecipientTest.java | 27 +
.../james/mdn/fields/OriginalRecipientTest.java | 26 +
.../james/mdn/fields/ReportingUserAgentTest.java | 29 +-
.../org/apache/james/mdn/MDNReportParserTest.scala | 6 +-
.../org/apache/james/jmap/draft/JMAPModule.java | 1 +
.../james/jmap/rfc8621/RFC8621MethodsModule.java | 2 +
.../methods/integration/SendMDNMethodTest.java | 2 +-
.../mailet/ExtractMDNOriginalJMAPMessageId.java | 86 +--
.../ExtractMDNOriginalJMAPMessageIdTest.java | 107 ----
.../distributed/DistributedMDNParseMethodTest.java | 65 ++
.../src/main/resources/eml/mdn_complex.eml | 41 ++
.../src/main/resources/eml/mdn_simple.eml | 22 +
.../rfc8621/contract/CustomMethodContract.scala | 9 +-
.../rfc8621/contract/MDNParseMethodContract.scala | 699 +++++++++++++++++++++
.../rfc8621/contract/SessionRoutesContract.scala | 9 +-
.../rfc8621/memory/MemoryMDNParseMethodTest.java} | 37 +-
.../org/apache/james/jmap/core/Capabilities.scala | 1 +
.../org/apache/james/jmap/core/Capability.scala | 11 +-
.../org/apache/james/jmap/core/Invocation.scala | 4 +
.../james/jmap/json/MDNParseSerializer.scala | 52 ++
.../scala/org/apache/james/jmap/mail/BlobId.scala | 7 +-
.../org/apache/james/jmap/mail/MDNParse.scala | 171 +++++
.../apache/james/jmap/method/MDNParseMethod.scala | 105 ++++
.../org/apache/james/jmap/method/Method.scala | 3 +-
32 files changed, 1661 insertions(+), 222 deletions(-)
diff --git a/mdn/src/main/java/org/apache/james/mdn/MDN.java b/mdn/src/main/java/org/apache/james/mdn/MDN.java
index 4a69548..b939d65 100644
--- a/mdn/src/main/java/org/apache/james/mdn/MDN.java
+++ b/mdn/src/main/java/org/apache/james/mdn/MDN.java
@@ -20,8 +20,11 @@
package org.apache.james.mdn;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.util.List;
import java.util.Objects;
+import java.util.Optional;
import java.util.Properties;
import javax.mail.BodyPart;
@@ -31,17 +34,23 @@ import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
+import org.apache.commons.io.IOUtils;
import org.apache.james.javax.MimeMultipartReport;
import org.apache.james.mime4j.Charsets;
+import org.apache.james.mime4j.dom.Entity;
import org.apache.james.mime4j.dom.Message;
import org.apache.james.mime4j.dom.Multipart;
+import org.apache.james.mime4j.dom.SingleBody;
import org.apache.james.mime4j.message.BasicBodyFactory;
import org.apache.james.mime4j.message.BodyPartBuilder;
import org.apache.james.mime4j.message.MultipartBuilder;
import org.apache.james.mime4j.stream.NameValuePair;
+import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
+import scala.util.Try;
+
public class MDN {
private static final NameValuePair UTF_8_CHARSET = new NameValuePair("charset", Charsets.UTF_8.name());
@@ -52,6 +61,7 @@ public class MDN {
public static class Builder {
private String humanReadableText;
private MDNReport report;
+ private Optional<Message> message = Optional.empty();
public Builder report(MDNReport report) {
Preconditions.checkNotNull(report);
@@ -65,25 +75,116 @@ public class MDN {
return this;
}
+ public Builder message(Optional<Message> message) {
+ this.message = message;
+ return this;
+ }
+
public MDN build() {
Preconditions.checkState(report != null);
Preconditions.checkState(humanReadableText != null);
Preconditions.checkState(!humanReadableText.trim().isEmpty());
- return new MDN(humanReadableText, report);
+ return new MDN(humanReadableText, report, message);
+ }
+ }
+
+ 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 {
+ if (!message.isMultipart()) {
+ throw new MDNParseContentTypeException("MDN Message must be multipart");
+ }
+ List<Entity> bodyParts = ((Multipart) message.getBody()).getBodyParts();
+ if (bodyParts.size() < 2) {
+ throw new MDNParseBodyPartInvalidException("MDN Message must contain at least two parts");
+ }
+ try {
+ var humanReadableTextEntity = bodyParts.get(0);
+ return extractHumanReadableText(humanReadableTextEntity)
+ .flatMap(humanReadableText -> extractMDNReport(bodyParts.get(1))
+ .map(report -> MDN.builder()
+ .humanReadableText(humanReadableText)
+ .report(report)
+ .message(extractOriginalMessage(bodyParts))
+ .build()))
+ .orElseThrow(() -> new MDNParseException("MDN can not extract. Body part is invalid"));
+ } catch (MDNParseException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new MDNParseException(e.getMessage(), e);
+ }
+ }
+
+ private static Optional<Message> extractOriginalMessage(List<Entity> bodyParts) {
+ if (bodyParts.size() < 3) {
+ return Optional.empty();
+ }
+ Entity originalMessagePart = bodyParts.get(2);
+ return Optional.of(originalMessagePart.getBody())
+ .filter(Message.class::isInstance)
+ .map(Message.class::cast);
+ }
+
+ public static Optional<String> extractHumanReadableText(Entity humanReadableTextEntity) throws IOException {
+ if (humanReadableTextEntity.getMimeType().equals("text/plain")) {
+ try (InputStream inputStream = ((SingleBody) humanReadableTextEntity.getBody()).getInputStream()) {
+ return Optional.of(IOUtils.toString(inputStream, humanReadableTextEntity.getCharset()));
+ }
+ }
+ return Optional.empty();
+ }
+
+ public static Optional<MDNReport> extractMDNReport(Entity reportEntity) {
+ if (!reportEntity.getMimeType().startsWith(DISPOSITION_CONTENT_TYPE)) {
+ return Optional.empty();
+ }
+ try (InputStream inputStream = ((SingleBody) reportEntity.getBody()).getInputStream()) {
+ Try<MDNReport> result = MDNReportParser.parse(inputStream, reportEntity.getCharset());
+ if (result.isSuccess()) {
+ return Optional.of(result.get());
+ } else {
+ return Optional.empty();
+ }
+ } catch (IOException e) {
+ return Optional.empty();
+ }
+ }
+
private final String humanReadableText;
private final MDNReport report;
+ private final Optional<Message> message;
- private MDN(String humanReadableText, MDNReport report) {
+ private MDN(String humanReadableText, MDNReport report, Optional<Message> message) {
this.humanReadableText = humanReadableText;
this.report = report;
+ this.message = message;
}
public String getHumanReadableText() {
@@ -94,6 +195,10 @@ public class MDN {
return report;
}
+ public Optional<Message> getOriginalMessage() {
+ return message;
+ }
+
public MimeMultipart asMultipart() throws MessagingException {
MimeMultipartReport multipart = new MimeMultipartReport();
multipart.setSubType(REPORT_SUB_TYPE);
@@ -155,13 +260,23 @@ public class MDN {
MDN mdn = (MDN) o;
return Objects.equals(this.humanReadableText, mdn.humanReadableText)
- && Objects.equals(this.report, mdn.report);
+ && Objects.equals(this.report, mdn.report)
+ && Objects.equals(this.message, mdn.message);
}
return false;
}
@Override
public final int hashCode() {
- return Objects.hash(humanReadableText, report);
+ return Objects.hash(humanReadableText, report, message);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("humanReadableText", humanReadableText)
+ .add("report", report)
+ .add("message", message)
+ .toString();
}
}
diff --git a/mdn/src/main/java/org/apache/james/mdn/MDNReport.java b/mdn/src/main/java/org/apache/james/mdn/MDNReport.java
index 5215a83..5527d91 100644
--- a/mdn/src/main/java/org/apache/james/mdn/MDNReport.java
+++ b/mdn/src/main/java/org/apache/james/mdn/MDNReport.java
@@ -204,6 +204,10 @@ public class MDNReport {
return errorFields;
}
+ public ImmutableList<ExtensionField> getExtensionFields() {
+ return extensionFields;
+ }
+
public String formattedValue() {
Stream<Optional<? extends Field>> definedFields =
Stream.of(
diff --git a/mdn/src/main/java/org/apache/james/mdn/fields/ExtensionField.java b/mdn/src/main/java/org/apache/james/mdn/fields/ExtensionField.java
index 9f102d2..476366c 100644
--- a/mdn/src/main/java/org/apache/james/mdn/fields/ExtensionField.java
+++ b/mdn/src/main/java/org/apache/james/mdn/fields/ExtensionField.java
@@ -90,4 +90,12 @@ public class ExtensionField implements Field {
public String toString() {
return formattedValue();
}
+
+ public String getFieldName() {
+ return fieldName;
+ }
+
+ public String getRawValue() {
+ return rawValue;
+ }
}
diff --git a/mdn/src/main/java/org/apache/james/mdn/fields/FinalRecipient.java b/mdn/src/main/java/org/apache/james/mdn/fields/FinalRecipient.java
index 14bfb4a..0e5ade2 100644
--- a/mdn/src/main/java/org/apache/james/mdn/fields/FinalRecipient.java
+++ b/mdn/src/main/java/org/apache/james/mdn/fields/FinalRecipient.java
@@ -79,7 +79,11 @@ public class FinalRecipient implements Field {
@Override
public String formattedValue() {
- return FIELD_NAME + ": " + addressType.getType() + "; " + finalRecipient.formatted();
+ return FIELD_NAME + ": " + fieldValue();
+ }
+
+ public String fieldValue() {
+ return addressType.getType() + "; " + finalRecipient.formatted();
}
@Override
diff --git a/mdn/src/main/java/org/apache/james/mdn/fields/OriginalRecipient.java b/mdn/src/main/java/org/apache/james/mdn/fields/OriginalRecipient.java
index 5349706..01ea441 100644
--- a/mdn/src/main/java/org/apache/james/mdn/fields/OriginalRecipient.java
+++ b/mdn/src/main/java/org/apache/james/mdn/fields/OriginalRecipient.java
@@ -99,7 +99,11 @@ public class OriginalRecipient implements Field {
@Override
public String formattedValue() {
- return FIELD_NAME + ": " + addressType.getType() + "; " + originalRecipient.formatted();
+ return FIELD_NAME + ": " + fieldValue();
+ }
+
+ public String fieldValue() {
+ return addressType.getType() + "; " + originalRecipient.formatted();
}
@Override
diff --git a/mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java b/mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java
index eddddfe..a5f59b6 100644
--- a/mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java
+++ b/mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java
@@ -23,6 +23,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.function.Predicate;
+import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
/**
@@ -87,8 +88,11 @@ public class ReportingUserAgent implements Field {
@Override
public String formattedValue() {
- return FIELD_NAME + ": " + userAgentName + "; "
- + userAgentProduct.orElse("");
+ return FIELD_NAME + ": " + fieldValue();
+ }
+
+ public String fieldValue() {
+ return Joiner.on("; ").skipNulls().join(userAgentName, userAgentProduct.orElse(null));
}
@Override
diff --git a/mdn/src/test/java/org/apache/james/mdn/MDNReportFormattingTest.java b/mdn/src/test/java/org/apache/james/mdn/MDNReportFormattingTest.java
index 48616a0..8b041e5 100644
--- a/mdn/src/test/java/org/apache/james/mdn/MDNReportFormattingTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/MDNReportFormattingTest.java
@@ -298,7 +298,7 @@ class MDNReportFormattingTest {
.formattedValue();
assertThat(report)
- .isEqualTo("Reporting-UA: UA_name; \r\n" +
+ .isEqualTo("Reporting-UA: UA_name\r\n" +
"Original-Recipient: rfc822; originalRecipient\r\n" +
"Final-Recipient: rfc822; final_recipient\r\n" +
"Original-Message-ID: original_message_id\r\n" +
diff --git a/mdn/src/test/java/org/apache/james/mdn/MDNTest.java b/mdn/src/test/java/org/apache/james/mdn/MDNTest.java
index 0095707..0292e74 100644
--- a/mdn/src/test/java/org/apache/james/mdn/MDNTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/MDNTest.java
@@ -29,11 +29,23 @@ import java.util.regex.Pattern;
import javax.mail.internet.MimeMessage;
import org.apache.james.mdn.action.mode.DispositionActionMode;
+import org.apache.james.mdn.fields.AddressType;
import org.apache.james.mdn.fields.Disposition;
+import org.apache.james.mdn.fields.ExtensionField;
+import org.apache.james.mdn.fields.FinalRecipient;
+import org.apache.james.mdn.fields.Gateway;
+import org.apache.james.mdn.fields.OriginalRecipient;
+import org.apache.james.mdn.fields.ReportingUserAgent;
+import org.apache.james.mdn.fields.Text;
+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.DefaultMessageWriter;
+import org.apache.james.mime4j.message.MultipartBuilder;
+import org.apache.james.mime4j.message.SingleBodyBuilder;
import org.junit.jupiter.api.Test;
import nl.jqno.equalsverifier.EqualsVerifier;
@@ -215,6 +227,198 @@ class MDNTest {
.containsPattern(Pattern.compile("Content-Type: multipart/report;.*(\r\n.+)*report-type=disposition-notification.*(\r\n.+)*\r\n\r\n"));
}
+ @Test
+ public void parseShouldThrowWhenNonMultipartMessage() throws Exception {
+ Message message = Message.Builder.of()
+ .setBody("content", StandardCharsets.UTF_8)
+ .build();
+ assertThatThrownBy(() -> MDN.parse(message))
+ .isInstanceOf(MDN.MDNParseContentTypeException.class)
+ .hasMessage("MDN Message must be multipart");
+ }
+
+ @Test
+ public void parseShouldThrowWhenMultipartWithSinglePart() throws Exception {
+ Message message = Message.Builder.of()
+ .setBody(MultipartBuilder.create()
+ .setSubType("report")
+ .addTextPart("content", StandardCharsets.UTF_8)
+ .build())
+ .build();
+ assertThatThrownBy(() -> MDN.parse(message))
+ .isInstanceOf(MDN.MDNParseBodyPartInvalidException.class)
+ .hasMessage("MDN Message must contain at least two parts");
+ }
+
+ @Test
+ public void parseShouldThrowWhenSecondPartWithBadContentType() 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(MDN.MDNParseException.class)
+ .hasMessage("MDN can not extract. Body part is invalid");
+ }
+
+ @Test
+ public void parseShouldFailWhenMDNMissingMustBeProperties() throws Exception {
+ Message message = Message.Builder.of()
+ .setBody(MultipartBuilder.create("report")
+ .addTextPart("first", StandardCharsets.UTF_8)
+ .addBodyPart(BodyPartBuilder
+ .create()
+ .setBody(SingleBodyBuilder.create()
+ .setText("Final-Recipient: rfc822; final_recipient")
+ .buildText())
+ .setContentType("message/disposition-notification")
+ .build())
+ .build())
+ .build();
+ assertThatThrownBy(() -> MDN.parse(message))
+ .isInstanceOf(MDN.MDNParseException.class)
+ .hasMessage("MDN can not extract. Body part is invalid");
+ }
+
+ @Test
+ public void parseShouldSuccessWithValidMDN() 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: <or...@message.id>\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();
+ MDN mdnActual = MDN.parse(message);
+ MDNReport mdnReportExpect = MDNReport.builder()
+ .reportingUserAgentField(ReportingUserAgent.builder()
+ .userAgentName("UA_name")
+ .userAgentProduct("UA_product")
+ .build())
+ .gatewayField(Gateway.builder()
+ .nameType(AddressType.RFC_822)
+ .name(Text.fromRawText("apache.org"))
+ .build())
+ .originalRecipientField(OriginalRecipient.builder()
+ .originalRecipient(Text.fromRawText("originalRecipient"))
+ .addressType(AddressType.RFC_822)
+ .build())
+ .finalRecipientField(FinalRecipient.builder()
+ .finalRecipient(Text.fromRawText("final_recipient"))
+ .addressType(AddressType.RFC_822)
+ .build())
+ .originalMessageIdField("<or...@message.id>")
+ .dispositionField(Disposition.builder()
+ .actionMode(DispositionActionMode.Automatic)
+ .sendingMode(DispositionSendingMode.Automatic)
+ .type(DispositionType.Processed)
+ .addModifier(DispositionModifier.Error)
+ .addModifier(DispositionModifier.Failed)
+ .build())
+ .addErrorField("Message1")
+ .addErrorField("Message2")
+ .withExtensionField(ExtensionField.builder()
+ .fieldName("X-OPENPAAS-IP")
+ .rawValue(" 177.177.177.77")
+ .build())
+ .withExtensionField(ExtensionField.builder()
+ .fieldName("X-OPENPAAS-PORT")
+ .rawValue(" 8000")
+ .build())
+ .build();
+
+ MDN mdnExpect = MDN.builder()
+ .report(mdnReportExpect)
+ .humanReadableText("first")
+ .build();
+ assertThat(mdnActual).isEqualTo(mdnExpect);
+ }
+
+ @Test
+ public void parseShouldSuccessWithMDNHasMinimalProperties() throws Exception {
+ Message message = Message.Builder.of()
+ .setBody(MultipartBuilder.create("report")
+ .addTextPart("first", StandardCharsets.UTF_8)
+ .addBodyPart(BodyPartBuilder
+ .create()
+ .setBody(SingleBodyBuilder.create()
+ .setText("Final-Recipient: rfc822; final_recipient\r\n" +
+ "Disposition: automatic-action/MDN-sent-automatically;processed/error,failed\r\n" +
+ "".replace(System.lineSeparator(), "\r\n").strip())
+ .buildText())
+ .setContentType("message/disposition-notification")
+ .build())
+ .build())
+ .build();
+ MDN mdnActual = MDN.parse(message);
+ MDNReport mdnReportExpect = MDNReport.builder()
+ .finalRecipientField(FinalRecipient.builder()
+ .finalRecipient(Text.fromRawText("final_recipient"))
+ .addressType(AddressType.RFC_822)
+ .build())
+ .dispositionField(Disposition.builder()
+ .actionMode(DispositionActionMode.Automatic)
+ .sendingMode(DispositionSendingMode.Automatic)
+ .type(DispositionType.Processed)
+ .addModifier(DispositionModifier.Error)
+ .addModifier(DispositionModifier.Failed)
+ .build())
+ .build();
+
+ MDN mdnExpect = MDN.builder()
+ .report(mdnReportExpect)
+ .humanReadableText("first")
+ .build();
+ assertThat(mdnActual).isEqualTo(mdnExpect);
+ }
+
+ @Test
+ public void includeOriginalMessageShouldReturnTrueWhenMDNHasContentOfOriginalMessage() throws Exception {
+ Message message = Message.Builder.of()
+ .setBody(MultipartBuilder.create("report")
+ .addTextPart("first", StandardCharsets.UTF_8)
+ .addBodyPart(BodyPartBuilder
+ .create()
+ .setBody(SingleBodyBuilder.create()
+ .setText(
+ "Final-Recipient: rfc822; final_recipient\r\n" +
+ "Disposition: automatic-action/MDN-sent-automatically;processed/error,failed\r\n" +
+ "".replace(System.lineSeparator(), "\r\n").strip())
+ .buildText())
+ .setContentType("message/disposition-notification")
+ .build())
+ .addBodyPart(
+ BodyPartBuilder.create()
+ .setBody(Message.Builder.of()
+ .setSubject("Subject of the original message")
+ .setBody("Content of the original message", StandardCharsets.UTF_8)
+ .build()))
+ .build())
+ .build();
+ MDN mdnActual = MDN.parse(message);
+ assertThat(mdnActual.getOriginalMessage()).isPresent();
+ }
+
private String asString(Message message) throws Exception {
return new String(DefaultMessageWriter.asBytes(message), StandardCharsets.UTF_8);
}
diff --git a/mdn/src/test/java/org/apache/james/mdn/fields/FinalRecipientTest.java b/mdn/src/test/java/org/apache/james/mdn/fields/FinalRecipientTest.java
index 72a233f..bb8c84d 100644
--- a/mdn/src/test/java/org/apache/james/mdn/fields/FinalRecipientTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/fields/FinalRecipientTest.java
@@ -99,4 +99,31 @@ class FinalRecipientTest {
.formattedValue())
.isEqualTo("Final-Recipient: rfc822; Plop\r\n Glark");
}
+
+ @Test
+ void fieldValueShouldThrowWhenFinalRecipientIsNull() {
+ assertThatThrownBy(() -> FinalRecipient.builder()
+ .build()
+ .fieldValue())
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void fieldValueShouldSuccessWhenNoSetAddress() {
+ assertThat(FinalRecipient.builder()
+ .finalRecipient(Text.fromRawText("Plop\nGlark"))
+ .build()
+ .fieldValue())
+ .isEqualTo("rfc822; Plop\r\n Glark");
+ }
+
+ @Test
+ void fieldValueShouldSuccessWithFullProperties() {
+ assertThat(FinalRecipient.builder()
+ .finalRecipient(Text.fromRawText("Plop\nGlark"))
+ .addressType(new AddressType("address type 1"))
+ .build()
+ .fieldValue())
+ .isEqualTo("address type 1; Plop\r\n Glark");
+ }
}
diff --git a/mdn/src/test/java/org/apache/james/mdn/fields/OriginalRecipientTest.java b/mdn/src/test/java/org/apache/james/mdn/fields/OriginalRecipientTest.java
index 3bf4fd5..964132e 100644
--- a/mdn/src/test/java/org/apache/james/mdn/fields/OriginalRecipientTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/fields/OriginalRecipientTest.java
@@ -99,4 +99,30 @@ class OriginalRecipientTest {
.formattedValue())
.isEqualTo("Original-Recipient: rfc822; multiline\r\n address");
}
+
+ @Test
+ void fieldValueShouldDontHaveSemiColonWhenAgentProductIsNull() {
+ assertThat(OriginalRecipient.builder()
+ .originalRecipient(Text.fromRawText("multiline\naddress"))
+ .build()
+ .fieldValue())
+ .isEqualTo("rfc822; multiline\r\n address");
+ }
+
+ @Test
+ void fieldValueShouldSuccessWithFullProperties() {
+ assertThat(OriginalRecipient.builder()
+ .originalRecipient(Text.fromRawText("multiline\naddress"))
+ .addressType(new AddressType("address"))
+ .build()
+ .fieldValue())
+ .isEqualTo("address; multiline\r\n address");
+ }
+
+ @Test
+ void fieldValueShouldFailWhenAgentNameIsNull() {
+ assertThatThrownBy(() -> OriginalRecipient.builder()
+ .build())
+ .isInstanceOf(NullPointerException.class);
+ }
}
diff --git a/mdn/src/test/java/org/apache/james/mdn/fields/ReportingUserAgentTest.java b/mdn/src/test/java/org/apache/james/mdn/fields/ReportingUserAgentTest.java
index 4134ccc..081c0ee 100644
--- a/mdn/src/test/java/org/apache/james/mdn/fields/ReportingUserAgentTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/fields/ReportingUserAgentTest.java
@@ -154,7 +154,7 @@ class ReportingUserAgentTest {
.userAgentName(USER_AGENT_NAME)
.build()
.formattedValue())
- .isEqualTo("Reporting-UA: name; ");
+ .isEqualTo("Reporting-UA: name");
}
@Test
@@ -186,4 +186,31 @@ class ReportingUserAgentTest {
.formattedValue())
.isEqualTo("Reporting-UA: name; product");
}
+
+ @Test
+ void fieldValueShouldDontHaveSemiColonWhenAgentProductIsNull() {
+ assertThat(ReportingUserAgent.builder()
+ .userAgentName(USER_AGENT_NAME)
+ .build()
+ .fieldValue())
+ .isEqualTo("name");
+ }
+
+ @Test
+ void fieldValueShouldSuccessWithFullProperties() {
+ assertThat(ReportingUserAgent.builder()
+ .userAgentName(USER_AGENT_NAME)
+ .userAgentProduct(USER_AGENT_PRODUCT)
+ .build()
+ .fieldValue())
+ .isEqualTo("name; product");
+ }
+
+ @Test
+ void fieldValueShouldFailWhenAgentNameIsNull() {
+ assertThatThrownBy(() -> ReportingUserAgent.builder()
+ .userAgentProduct(USER_AGENT_PRODUCT)
+ .build())
+ .isInstanceOf(NullPointerException.class);
+ }
}
diff --git a/mdn/src/test/scala/org/apache/james/mdn/MDNReportParserTest.scala b/mdn/src/test/scala/org/apache/james/mdn/MDNReportParserTest.scala
index 9eeda58..13cd419 100644
--- a/mdn/src/test/scala/org/apache/james/mdn/MDNReportParserTest.scala
+++ b/mdn/src/test/scala/org/apache/james/mdn/MDNReportParserTest.scala
@@ -47,7 +47,7 @@ class MDNReportParserTest {
|Error: Message2
|X-OPENPAAS-IP: 177.177.177.77
|X-OPENPAAS-PORT: 8000
- |""".replaceAllLiterally(System.lineSeparator(), "\r\n")
+ |""".replace(System.lineSeparator(), "\r\n")
.stripMargin
val expected = Some(MDNReport.builder
.reportingUserAgentField(ReportingUserAgent.builder
@@ -87,7 +87,7 @@ class MDNReportParserTest {
def parseShouldReturnMdnReportWhenMinimalSubset(): Unit = {
val minimal = """Final-Recipient: rfc822; final_recipient
|Disposition: automatic-action/MDN-sent-automatically;processed
- |""".replaceAllLiterally(System.lineSeparator(), "\r\n")
+ |""".replace(System.lineSeparator(), "\r\n")
.stripMargin
val disposition = Disposition.builder
.actionMode(DispositionActionMode.Automatic)
@@ -107,7 +107,7 @@ class MDNReportParserTest {
val duplicated = """Final-Recipient: rfc822; final_recipient
|Final-Recipient: rfc822; final_recipient
|Disposition: automatic-action/MDN-sent-automatically;processed
- |""".replaceAllLiterally(System.lineSeparator(), "\r\n")
+ |""".replace(System.lineSeparator(), "\r\n")
.stripMargin
val actual = MDNReportParser.parse(duplicated).toOption
assertThat(actual.isEmpty)
diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/draft/JMAPModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/draft/JMAPModule.java
index dd0d101..2ec83e1 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/draft/JMAPModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/draft/JMAPModule.java
@@ -139,6 +139,7 @@ public class JMAPModule extends AbstractModule {
supportedCapabilities.addBinding().toInstance(DefaultCapabilities.SHARES_CAPABILITY());
supportedCapabilities.addBinding().toInstance(DefaultCapabilities.VACATION_RESPONSE_CAPABILITY());
supportedCapabilities.addBinding().toInstance(DefaultCapabilities.SUBMISSION_CAPABILITY());
+ supportedCapabilities.addBinding().toInstance(DefaultCapabilities.MDN_CAPABILITY());
}
@ProvidesIntoSet
diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
index 2206467..f76c17d 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
@@ -42,6 +42,7 @@ import org.apache.james.jmap.method.EmailQueryMethod;
import org.apache.james.jmap.method.EmailSetMethod;
import org.apache.james.jmap.method.EmailSubmissionSetMethod;
import org.apache.james.jmap.method.IdentityGetMethod;
+import org.apache.james.jmap.method.MDNParseMethod;
import org.apache.james.jmap.method.MailboxChangesMethod;
import org.apache.james.jmap.method.MailboxGetMethod;
import org.apache.james.jmap.method.MailboxQueryMethod;
@@ -99,6 +100,7 @@ public class RFC8621MethodsModule extends AbstractModule {
methods.addBinding().to(IdentityGetMethod.class);
methods.addBinding().to(ThreadChangesMethod.class);
methods.addBinding().to(ThreadGetMethod.class);
+ methods.addBinding().to(MDNParseMethod.class);
Multibinder<JMAPRoutes> routes = Multibinder.newSetBinder(binder(), JMAPRoutes.class);
routes.addBinding().to(SessionRoutes.class);
diff --git a/server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SendMDNMethodTest.java b/server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SendMDNMethodTest.java
index bd85b44..72896bc 100644
--- a/server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SendMDNMethodTest.java
+++ b/server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SendMDNMethodTest.java
@@ -365,7 +365,7 @@ public abstract class SendMDNMethodTest {
.get("/download/" + blobId)
.then()
.statusCode(200)
- .body(containsString("Reporting-UA: reportingUA;"))
+ .body(containsString("Reporting-UA: reportingUA"))
.body(containsString("Final-Recipient: rfc822; homer@domain.tld"))
.body(containsString("Original-Message-ID: "))
.body(containsString("Disposition: automatic-action/MDN-sent-automatically;processed"));
diff --git a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageId.java b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageId.java
index 9cff197..31cd912 100644
--- a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageId.java
+++ b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageId.java
@@ -18,9 +18,6 @@
****************************************************************/
package org.apache.james.jmap.mailet;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.List;
import java.util.Optional;
import javax.inject.Inject;
@@ -34,13 +31,9 @@ import org.apache.james.mailbox.exception.MailboxException;
import org.apache.james.mailbox.model.MessageId;
import org.apache.james.mailbox.model.MultimailboxesSearchQuery;
import org.apache.james.mailbox.model.SearchQuery;
+import org.apache.james.mdn.MDN;
import org.apache.james.mdn.MDNReport;
-import org.apache.james.mdn.MDNReportParser;
import org.apache.james.mdn.fields.OriginalMessageId;
-import org.apache.james.mime4j.dom.Entity;
-import org.apache.james.mime4j.dom.Message;
-import org.apache.james.mime4j.dom.Multipart;
-import org.apache.james.mime4j.dom.SingleBody;
import org.apache.james.mime4j.message.DefaultMessageBuilder;
import org.apache.james.server.core.MimeMessageInputStream;
import org.apache.james.user.api.UsersRepository;
@@ -50,11 +43,9 @@ import org.apache.mailet.base.GenericMailet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Iterables;
import reactor.core.publisher.Flux;
-import scala.util.Try;
/**
* This mailet handles MDN messages and define a header X-JAMES-MDN-JMAP-MESSAGE-ID referencing
@@ -64,7 +55,6 @@ public class ExtractMDNOriginalJMAPMessageId extends GenericMailet {
private static final Logger LOGGER = LoggerFactory.getLogger(ExtractMDNOriginalJMAPMessageId.class);
- private static final String MESSAGE_DISPOSITION_NOTIFICATION = "message/disposition-notification";
private static final String X_JAMES_MDN_JMAP_MESSAGE_ID = "X-JAMES-MDN-JMAP-MESSAGE-ID";
private final MailboxManager mailboxManager;
@@ -85,12 +75,17 @@ public class ExtractMDNOriginalJMAPMessageId extends GenericMailet {
MailAddress recipient = Iterables.getOnlyElement(mail.getRecipients());
MimeMessage mimeMessage = mail.getMessage();
- findReport(mimeMessage)
- .flatMap(this::parseReport)
- .flatMap(MDNReport::getOriginalMessageIdField)
- .map(OriginalMessageId::getOriginalMessageId)
- .flatMap(messageId -> findMessageIdForRFC822MessageId(messageId, recipient))
- .ifPresent(messageId -> setJmapMessageIdAsHeader(mimeMessage, messageId));
+ try {
+ var message = new DefaultMessageBuilder().parseMessage(new MimeMessageInputStream(mimeMessage));
+ Optional.of(MDN.parse(message))
+ .map(MDN::getReport)
+ .flatMap(MDNReport::getOriginalMessageIdField)
+ .map(OriginalMessageId::getOriginalMessageId)
+ .flatMap(messageId -> findMessageIdForRFC822MessageId(messageId, recipient))
+ .ifPresent(messageId -> setJmapMessageIdAsHeader(mimeMessage, messageId));
+ } catch (Exception e) {
+ throw new MessagingException("MDN can't be parse", e);
+ }
}
private void setJmapMessageIdAsHeader(MimeMessage mimeMessage, MessageId messageId) {
@@ -117,63 +112,6 @@ public class ExtractMDNOriginalJMAPMessageId extends GenericMailet {
return Optional.empty();
}
- private Optional<MDNReport> parseReport(Entity report) {
- LOGGER.debug("Parsing report");
- try (InputStream inputStream = ((SingleBody) report.getBody()).getInputStream()) {
- Try<MDNReport> result = MDNReportParser.parse(inputStream, report.getCharset());
- if (result.isSuccess()) {
- return Optional.of(result.get());
- } else {
- LOGGER.error("unable to parse MESSAGE_DISPOSITION_NOTIFICATION part", result.failed().get());
- return Optional.empty();
- }
- } catch (IOException e) {
- LOGGER.error("unable to parse MESSAGE_DISPOSITION_NOTIFICATION part", e);
- return Optional.empty();
- }
- }
-
- private Optional<Entity> findReport(MimeMessage mimeMessage) {
- return parseMessage(mimeMessage).flatMap(this::extractReport);
- }
-
- @VisibleForTesting Optional<Entity> extractReport(Message message) {
- LOGGER.debug("Extracting report");
- if (!message.isMultipart()) {
- LOGGER.debug("MDN Message must be multipart");
- return Optional.empty();
- }
- List<Entity> bodyParts = ((Multipart) message.getBody()).getBodyParts();
- if (bodyParts.size() < 2) {
- LOGGER.debug("MDN Message must contain at least two parts");
- return Optional.empty();
- }
- Entity report = bodyParts.get(1);
- if (!isDispositionNotification(report)) {
- LOGGER.debug("MDN Message second part must be of type " + MESSAGE_DISPOSITION_NOTIFICATION);
- return Optional.empty();
- }
- return Optional.of(report);
- }
-
- private boolean isDispositionNotification(Entity entity) {
- return entity
- .getMimeType()
- .startsWith(MESSAGE_DISPOSITION_NOTIFICATION);
- }
-
- private Optional<Message> parseMessage(MimeMessage mimeMessage) {
- LOGGER.debug("Parsing message");
- try {
- Message message = new DefaultMessageBuilder()
- .parseMessage(new MimeMessageInputStream(mimeMessage));
- return Optional.of(message);
- } catch (IOException | MessagingException e) {
- LOGGER.error("unable to parse message", e);
- return Optional.empty();
- }
- }
-
@Override
public String getMailetInfo() {
return "ExtractMDNOriginalJMAPMessageId";
diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageIdTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageIdTest.java
deleted file mode 100644
index 8243ef0..0000000
--- a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/mailet/ExtractMDNOriginalJMAPMessageIdTest.java
+++ /dev/null
@@ -1,107 +0,0 @@
-/****************************************************************
- * 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; homer@linagora.com\n" +
- "Original-Message-ID: <15...@apache.org>\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);
- }
-}
\ No newline at end of file
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedMDNParseMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedMDNParseMethodTest.java
new file mode 100644
index 0000000..64d7d74
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedMDNParseMethodTest.java
@@ -0,0 +1,65 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.distributed;
+
+import org.apache.james.CassandraExtension;
+import org.apache.james.CassandraRabbitMQJamesConfiguration;
+import org.apache.james.CassandraRabbitMQJamesServerMain;
+import org.apache.james.DockerElasticSearchExtension;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.MDNParseMethodContract;
+import org.apache.james.mailbox.cassandra.ids.CassandraMessageId;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.modules.AwsS3BlobStoreExtension;
+import org.apache.james.modules.RabbitMQExtension;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.apache.james.modules.blobstore.BlobStoreConfiguration;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.datastax.driver.core.utils.UUIDs;
+
+public class DistributedMDNParseMethodTest implements MDNParseMethodContract {
+ public static final CassandraMessageId.Factory MESSAGE_ID_FACTORY = new CassandraMessageId.Factory();
+
+ @RegisterExtension
+ static JamesServerExtension testExtension = new JamesServerBuilder<CassandraRabbitMQJamesConfiguration>(tmpDir ->
+ CassandraRabbitMQJamesConfiguration.builder()
+ .workingDirectory(tmpDir)
+ .configurationFromClasspath()
+ .blobStore(BlobStoreConfiguration.builder()
+ .s3()
+ .disableCache()
+ .deduplication()
+ .noCryptoConfig())
+ .build())
+ .extension(new DockerElasticSearchExtension())
+ .extension(new CassandraExtension())
+ .extension(new RabbitMQExtension())
+ .extension(new AwsS3BlobStoreExtension())
+ .server(configuration -> CassandraRabbitMQJamesServerMain.createServer(configuration)
+ .overrideWith(new TestJMAPServerModule()))
+ .build();
+
+ @Override
+ public MessageId randomMessageId() {
+ return MESSAGE_ID_FACTORY.of(UUIDs.timeBased());
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/mdn_complex.eml b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/mdn_complex.eml
new file mode 100644
index 0000000..a9f9832
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/mdn_complex.eml
@@ -0,0 +1,41 @@
+Return-Path: <tu...@linagora.com>
+X-JAMES-MDN-JMAP-MESSAGE-ID: 49c73510-9108-11eb-a5a7-11eb7059e7ef
+Delivered-To: magiclan@linagora.com
+MIME-Version: 1.0
+Content-Type: multipart/report; report-type=disposition-notification;
+ boundary="-=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-"
+To: magiclan@linagora.com
+X-LINAGORA-Copy-Delivery-Done: 1
+From: tungexplorer@linagora.com
+Subject: Read: test
+Date: Tue, 30 Mar 2021 03:31:50 +0000
+Message-ID: <Mi...@linagora.com>
+
+---=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-
+Content-Type: text/plain; charset=UTF-8
+
+To: magiclan@linagora.com
+Subject: test
+Message was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina Time)
+---=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-
+Content-Type: message/disposition-notification; charset=UTF-8
+
+Reporting-UA: OpenPaaS Unified Inbox; UA_Product
+Original-Recipient: rfc822; tungexplorer@linagora.com
+Final-Recipient: rfc822; tungexplorer@linagora.com
+Original-Message-ID: <63...@linagora.com>
+Disposition: manual-action/MDN-sent-manually;displayed
+Error: Message1
+Error: Message2
+X-OPENPAAS-IP: 177.177.177.77
+X-OPENPAAS-PORT: 8000
+
+---=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-
+Content-Type: message/rfc822
+
+Subject: test
+To: magiclan@linagora.com
+Content-Type: text/plain
+
+This is content of original message, that already on third bodypart
+---=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/mdn_simple.eml b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/mdn_simple.eml
new file mode 100644
index 0000000..966358a
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/mdn_simple.eml
@@ -0,0 +1,22 @@
+Return-Path: <tu...@linagora.com>
+X-JAMES-MDN-JMAP-MESSAGE-ID: 49c73510-9108-11eb-a5a7-11eb7059e7ef
+Delivered-To: magiclan@linagora.com
+MIME-Version: 1.0
+Content-Type: multipart/report; report-type=disposition-notification;
+ boundary="-=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-"
+To: magiclan@linagora.com
+X-LINAGORA-Copy-Delivery-Done: 1
+From: tungexplorer@linagora.com
+Subject: Read: test
+Date: Tue, 30 Mar 2021 03:31:50 +0000
+Message-ID: <Mi...@linagora.com>
+
+---=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-
+Content-Type: text/plain; charset=UTF-8
+
+This is simple body of human-readable part
+---=Part.e.fbba3f3ef449cf21.1788130035a.be0dce94fc2486bf=-
+Content-Type: message/disposition-notification; charset=UTF-8
+
+Final-Recipient: rfc822; tungexplorer@linagora.com
+Disposition: manual-action/MDN-sent-manually;displayed
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
index b399781..a0ad6f5 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
@@ -78,7 +78,8 @@ object CustomMethodContract {
| "urn:apache:james:params:jmap:mail:quota": {},
| "$CUSTOM": {"custom": "property"},
| "urn:apache:james:params:jmap:mail:shares": {},
- | "urn:ietf:params:jmap:vacationresponse":{}
+ | "urn:ietf:params:jmap:vacationresponse":{},
+ | "urn:ietf:params:jmap:mdn":{}
| },
| "accounts" : {
| "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6" : {
@@ -115,7 +116,8 @@ object CustomMethodContract {
| "urn:apache:james:params:jmap:mail:quota": {},
| "urn:apache:james:params:jmap:mail:shares": {},
| "$CUSTOM": {"custom": "property"},
- | "urn:ietf:params:jmap:vacationresponse":{}
+ | "urn:ietf:params:jmap:vacationresponse":{},
+ | "urn:ietf:params:jmap:mdn":{}
| }
| }
| },
@@ -127,7 +129,8 @@ object CustomMethodContract {
| "urn:apache:james:params:jmap:mail:quota": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
| "urn:apache:james:params:jmap:mail:shares": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
| "$CUSTOM": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
- | "urn:ietf:params:jmap:vacationresponse": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
+ | "urn:ietf:params:jmap:vacationresponse": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "urn:ietf:params:jmap:mdn": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
| },
| "username" : "bob@domain.tld",
| "apiUrl" : "http://domain.com/jmap",
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
new file mode 100644
index 0000000..37f60b0
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNParseMethodContract.scala
@@ -0,0 +1,699 @@
+/****************************************************************
+ * 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.mail.MDNParseRequest
+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 awaitConditionFactory = 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 parseShouldSuccessWithMDNHasAllProperties(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_complex.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "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",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: magiclan@linagora.com\\r\\nSubject: test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; UA_Product",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "mdn-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "originalMessageId": "<63...@linagora.com>",
+ | "originalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "includeOriginalMessage": true,
+ | "error": [
+ | "Message1",
+ | "Message2"
+ | ],
+ | "extensionFields": {
+ | "X-OPENPAAS-IP" : " 177.177.177.77",
+ | "X-OPENPAAS-PORT" : " 8000"
+ | }
+ | }
+ | }
+ | }, "c1" ]]
+ |}""".stripMargin)
+ }
+
+ @Test
+ def parseShouldAcceptSeveralIds(guiceJamesServer: GuiceJamesServer): Unit = {
+ val path: MailboxPath = MailboxPath.inbox(BOB)
+ val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+ mailboxProbe.createMailbox(path)
+
+ val messageId1: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn_simple.eml")))
+ .getMessageId
+ val messageId2: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn_simple.eml")))
+ .getMessageId
+ val messageId3: MessageId = mailboxProbe
+ .appendMessage(BOB.asString(), path, AppendCommand.from(
+ ClassLoaderUtils.getSystemResourceAsSharedStream("eml/mdn_simple.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${messageId1.serialize()}", "${messageId2.serialize()}", "${messageId3.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)
+ .inPath("methodResponses[0][1].parsed")
+ .isEqualTo(
+ s"""{
+ | "1": {
+ | "subject": "Read: test",
+ | "textBody": "This is simple body of human-readable part",
+ | "finalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "includeOriginalMessage": false,
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "mdn-sent-manually",
+ | "type": "displayed"
+ | }
+ | },
+ | "2": {
+ | "subject": "Read: test",
+ | "textBody": "This is simple body of human-readable part",
+ | "finalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "includeOriginalMessage": false,
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "mdn-sent-manually",
+ | "type": "displayed"
+ | }
+ | },
+ | "3": {
+ | "subject": "Read: test",
+ | "textBody": "This is simple body of human-readable part",
+ | "finalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "includeOriginalMessage": false,
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "mdn-sent-manually",
+ | "type": "displayed"
+ | }
+ | }
+ |}""".stripMargin)
+ }
+
+ @Test
+ def parseShouldSuccessWithMDNHasMinimalProperties(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_simple.eml")))
+ .getMessageId
+
+ val request: String =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "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",
+ | "parsed": {
+ | "${messageId.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "This is simple body of human-readable part",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "mdn-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "includeOriginalMessage": false
+ | }
+ | }
+ | }, "c1" ]]
+ |}""".stripMargin)
+ }
+
+
+ @Test
+ def mdnParseShouldFailWhenWrongAccountId(): Unit = {
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "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(MDNParseRequest.MAXIMUM_NUMBER_OF_BLOB_IDS + 1).toArray;
+ val blogIdsJson = Json.stringify(Json.arr(blogIds)).replace("[[", "[").replace("]]", "]");
+ val request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "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:core",
+ | "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:core",
+ | "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 request =
+ s"""{
+ | "using": [
+ | "urn:ietf:params:jmap:core",
+ | "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_complex.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:core",
+ | "urn:ietf:params:jmap:mdn",
+ | "urn:ietf:params:jmap:mail"],
+ | "methodCalls": [[
+ | "MDN/parse",
+ | {
+ | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "blobIds": [ "${blobIdParsable.serialize()}", "${blobIdNotParsable.serialize()}", "${blobIdNotFound.serialize()}" ]
+ | },
+ | "c1"]]
+ |}""".stripMargin
+
+ awaitConditionFactory.untilAsserted{
+ () => {
+ 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": ["${blobIdNotFound.serialize()}"],
+ | "notParsable": ["${blobIdNotParsable.serialize()}"],
+ | "parsed": {
+ | "${blobIdParsable.serialize()}": {
+ | "subject": "Read: test",
+ | "textBody": "To: magiclan@linagora.com\\r\\nSubject: test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina Time)",
+ | "reportingUA": "OpenPaaS Unified Inbox; UA_Product",
+ | "disposition": {
+ | "actionMode": "manual-action",
+ | "sendingMode": "mdn-sent-manually",
+ | "type": "displayed"
+ | },
+ | "finalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "originalMessageId": "<63...@linagora.com>",
+ | "originalRecipient": "rfc822; tungexplorer@linagora.com",
+ | "includeOriginalMessage": true,
+ | "error": [
+ | "Message1",
+ | "Message2"
+ | ],
+ | "extensionFields": {
+ | "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:core",
+ | "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, urn:ietf:params:jmap:core"
+ | },
+ | "c1"]]
+ |}""".stripMargin)
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/SessionRoutesContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/SessionRoutesContract.scala
index 7491ebf..7f71277 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/SessionRoutesContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/SessionRoutesContract.scala
@@ -69,7 +69,8 @@ object SessionRoutesContract {
| },
| "urn:apache:james:params:jmap:mail:quota": {},
| "urn:apache:james:params:jmap:mail:shares": {},
- | "urn:ietf:params:jmap:vacationresponse":{}
+ | "urn:ietf:params:jmap:vacationresponse":{},
+ | "urn:ietf:params:jmap:mdn":{}
| },
| "accounts" : {
| "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6" : {
@@ -105,7 +106,8 @@ object SessionRoutesContract {
| },
| "urn:apache:james:params:jmap:mail:quota": {},
| "urn:apache:james:params:jmap:mail:shares": {},
- | "urn:ietf:params:jmap:vacationresponse":{}
+ | "urn:ietf:params:jmap:vacationresponse":{},
+ | "urn:ietf:params:jmap:mdn":{}
| }
| }
| },
@@ -116,7 +118,8 @@ object SessionRoutesContract {
| "urn:ietf:params:jmap:mail" : "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
| "urn:apache:james:params:jmap:mail:quota": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
| "urn:apache:james:params:jmap:mail:shares": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
- | "urn:ietf:params:jmap:vacationresponse": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
+ | "urn:ietf:params:jmap:vacationresponse": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+ | "urn:ietf:params:jmap:mdn": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
| },
| "username" : "bob@domain.tld",
| "apiUrl" : "http://domain.com/jmap",
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMDNParseMethodTest.java
similarity index 50%
copy from server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMDNParseMethodTest.java
index c059001..e9191e1 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMDNParseMethodTest.java
@@ -17,22 +17,31 @@
* under the License. *
****************************************************************/
-package org.apache.james.jmap.mail
+package org.apache.james.jmap.rfc8621.memory;
-import eu.timepit.refined.collection.NonEmpty
-import eu.timepit.refined.refineV
-import eu.timepit.refined.types.string.NonEmptyString
-import org.apache.james.mailbox.model.MessageId
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.MDNParseMethodContract;
+import org.apache.james.mailbox.inmemory.InMemoryMessageId;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
-import scala.util.{Failure, Success, Try}
+import java.util.concurrent.ThreadLocalRandom;
-object BlobId {
- def of(string: String): Try[BlobId] = refineV[NonEmpty](string) match {
- case scala.Right(value) => Success(BlobId(value))
- case Left(e) => Failure(new IllegalArgumentException(e))
+import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE;
+
+public class MemoryMDNParseMethodTest implements MDNParseMethodContract {
+ @RegisterExtension
+ static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+ .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+ .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+ .overrideWith(new TestJMAPServerModule()))
+ .build();
+
+ @Override
+ public MessageId randomMessageId() {
+ return InMemoryMessageId.of(ThreadLocalRandom.current().nextInt(100000) + 100);
}
- def of(messageId: MessageId): Try[BlobId] = of(messageId.serialize())
- def of(messageId: MessageId, partId: PartId): Try[BlobId] = of(s"${messageId.serialize()}_${partId.serialize}")
}
-
-case class BlobId(value: NonEmptyString)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala
index 654fb85..cb6daed 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala
@@ -48,6 +48,7 @@ object DefaultCapabilities {
MayCreateTopLevelMailbox(true)))
val QUOTA_CAPABILITY = QuotaCapability()
val SHARES_CAPABILITY = SharesCapability()
+ val MDN_CAPABILITY = MDNCapability()
val VACATION_RESPONSE_CAPABILITY = VacationResponseCapability()
val SUBMISSION_CAPABILITY = SubmissionCapability()
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
index a73ad1a..d69ab77 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
@@ -20,13 +20,12 @@
package org.apache.james.jmap.core
import java.net.URL
-
import eu.timepit.refined
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.collection.NonEmpty
import eu.timepit.refined.string.Uri
-import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, EMAIL_SUBMISSION, JAMES_QUOTA, JAMES_SHARES, JMAP_CORE, JMAP_MAIL, JMAP_VACATION_RESPONSE, JMAP_WEBSOCKET}
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, EMAIL_SUBMISSION, JAMES_QUOTA, JAMES_SHARES, JMAP_CORE, JMAP_MAIL, JMAP_MDN, JMAP_VACATION_RESPONSE, JMAP_WEBSOCKET}
import org.apache.james.jmap.core.CoreCapabilityProperties.CollationAlgorithm
import org.apache.james.jmap.core.MailCapability.EmailQuerySortOption
import org.apache.james.jmap.core.UnsignedInt.{UnsignedInt, UnsignedIntConstraint}
@@ -48,6 +47,7 @@ object CapabilityIdentifier {
val JMAP_WEBSOCKET: CapabilityIdentifier = "urn:ietf:params:jmap:websocket"
val JAMES_QUOTA: CapabilityIdentifier = "urn:apache:james:params:jmap:mail:quota"
val JAMES_SHARES: CapabilityIdentifier = "urn:apache:james:params:jmap:mail:shares"
+ val JMAP_MDN: CapabilityIdentifier = "urn:ietf:params:jmap:mdn"
}
trait CapabilityProperties {
@@ -148,6 +148,13 @@ final case class SharesCapabilityProperties() extends CapabilityProperties {
final case class SharesCapability(properties: SharesCapabilityProperties = SharesCapabilityProperties(),
identifier: CapabilityIdentifier = JAMES_SHARES) extends Capability
+final case class MDNCapabilityProperties() extends CapabilityProperties {
+ override def jsonify(): JsObject = Json.obj()
+}
+
+final case class MDNCapability(properties: MDNCapabilityProperties = MDNCapabilityProperties(),
+ identifier: CapabilityIdentifier = JMAP_MDN) extends Capability
+
final case class VacationResponseCapabilityProperties() extends CapabilityProperties {
override def jsonify(): JsObject = Json.obj()
}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala
index ad2ced8..7303701 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Invocation.scala
@@ -81,4 +81,8 @@ object ErrorCode {
case object UnsupportedFilter extends ErrorCode {
override def code: String = "unsupportedFilter"
}
+
+ case object RequestTooLarge extends ErrorCode {
+ override def code: String = "requestTooLarge"
+ }
}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNParseSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNParseSerializer.scala
new file mode 100644
index 0000000..c611b2c
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNParseSerializer.scala
@@ -0,0 +1,52 @@
+/****************************************************************
+ * 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.json
+
+import org.apache.james.jmap.mail.{BlobId, BlobIds, ErrorField, FinalRecipientField, ForEmailIdField, IncludeOriginalMessageField, MDNDisposition, MDNNotFound, MDNNotParsable, MDNParseRequest, MDNParseResponse, MDNParsed, OriginalMessageIdField, OriginalRecipientField, ReportUAField, SubjectField, TextBodyField}
+import play.api.libs.json._
+
+object MDNParseSerializer {
+ private implicit val blobIdReads: Reads[BlobId] = Json.valueReads[BlobId]
+ private implicit val blobIdsWrites: Format[BlobIds] = Json.valueFormat[BlobIds]
+ private implicit val mdnNotFoundWrites: Writes[MDNNotFound] = Json.valueWrites[MDNNotFound]
+ private implicit val mdnNotParsableWrites: Writes[MDNNotParsable] = Json.valueWrites[MDNNotParsable]
+ private implicit val mdnDispositionWrites: Writes[MDNDisposition] = Json.writes[MDNDisposition]
+ private implicit val mdnForEmailIdFieldWrites: Writes[ForEmailIdField] = Json.valueWrites[ForEmailIdField]
+ private implicit val mdnSubjectFieldWrites: Writes[SubjectField] = Json.valueWrites[SubjectField]
+ private implicit val mdnTextBodyFieldWrites: Writes[TextBodyField] = Json.valueWrites[TextBodyField]
+ private implicit val mdnReportUAFieldWrites: Writes[ReportUAField] = Json.valueWrites[ReportUAField]
+ private implicit val mdnFinalRecipientFieldWrites: Writes[FinalRecipientField] = Json.valueWrites[FinalRecipientField]
+ private implicit val mdnOriginalMessageIdFieldWrites: Writes[OriginalMessageIdField] = Json.valueWrites[OriginalMessageIdField]
+ private implicit val mdnOriginalRecipientFieldFieldWrites: Writes[OriginalRecipientField] = Json.valueWrites[OriginalRecipientField]
+ private implicit val mdnIncludeOriginalMessageFieldWrites: Writes[IncludeOriginalMessageField] = Json.valueWrites[IncludeOriginalMessageField]
+ private implicit val mdnErrorFieldWrites: Writes[ErrorField] = Json.valueWrites[ErrorField]
+ private implicit val mdnParsedWrites: Writes[MDNParsed] = Json.writes[MDNParsed]
+ private implicit val parsedMapWrites: Writes[Map[BlobId, MDNParsed]] = mapWrites[BlobId, MDNParsed](s => s.value.value, mdnParsedWrites)
+
+ private implicit val mdnParseRequestReads: Reads[MDNParseRequest] = Json.reads[MDNParseRequest]
+
+ def deserializeMDNParseRequest(input: JsValue): JsResult[MDNParseRequest] = Json.fromJson[MDNParseRequest](input)
+
+ private implicit val mdnParseResponseWrites: Writes[MDNParseResponse] = Json.writes[MDNParseResponse]
+
+ def serialize(mdnParseResponse: MDNParseResponse): JsValue = {
+ Json.toJson(mdnParseResponse)
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala
index c059001..7620ea5 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/BlobId.scala
@@ -19,15 +19,14 @@
package org.apache.james.jmap.mail
-import eu.timepit.refined.collection.NonEmpty
import eu.timepit.refined.refineV
-import eu.timepit.refined.types.string.NonEmptyString
+import org.apache.james.jmap.core.Id
import org.apache.james.mailbox.model.MessageId
import scala.util.{Failure, Success, Try}
object BlobId {
- def of(string: String): Try[BlobId] = refineV[NonEmpty](string) match {
+ def of(string: String): Try[BlobId] = refineV[Id.IdConstraint](string) match {
case scala.Right(value) => Success(BlobId(value))
case Left(e) => Failure(new IllegalArgumentException(e))
}
@@ -35,4 +34,4 @@ object BlobId {
def of(messageId: MessageId, partId: PartId): Try[BlobId] = of(s"${messageId.serialize()}_${partId.serialize}")
}
-case class BlobId(value: NonEmptyString)
+case class BlobId(value: Id.Id)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
new file mode 100644
index 0000000..c7dd632
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNParse.scala
@@ -0,0 +1,171 @@
+/****************************************************************
+ * 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 org.apache.james.jmap.core.{AccountId, Id}
+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 java.util.Locale
+import scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+
+object MDNParse {
+ type UnparsedBlobId = String Refined Id.IdConstraint
+}
+
+case class BlobIds(value: Seq[UnparsedBlobId])
+
+case class RequestTooLargeException(description: String) extends Exception
+
+case object MDNParseRequest {
+ val MAXIMUM_NUMBER_OF_BLOB_IDS: 16 = 16
+}
+
+case class MDNParseRequest(accountId: AccountId,
+ blobIds: BlobIds) extends WithAccountId {
+
+ import MDNParseRequest._
+
+ def validate: Either[RequestTooLargeException, MDNParseRequest] = {
+ if (blobIds.value.length > MAXIMUM_NUMBER_OF_BLOB_IDS) {
+ 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 {
+ scala.Right(this)
+ }
+ }
+}
+
+case class MDNNotFound(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotFound): MDNNotFound = MDNNotFound(this.value ++ other.value)
+}
+
+object MDNNotParsable {
+ def merge(notParsable1: MDNNotParsable, notParsable2: MDNNotParsable): MDNNotParsable = MDNNotParsable(notParsable1.value ++ notParsable2.value)
+}
+
+case class MDNNotParsable(value: Set[UnparsedBlobId]) {
+ def merge(other: MDNNotParsable): MDNNotParsable = MDNNotParsable(this.value ++ other.value)
+}
+
+object MDNDisposition {
+ def fromJava(javaDisposition: JavaDisposition): MDNDisposition =
+ MDNDisposition(actionMode = javaDisposition.getActionMode.getValue,
+ sendingMode = javaDisposition.getSendingMode.getValue.toLowerCase(Locale.US),
+ `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 OriginalRecipientField(value: String) extends AnyVal
+
+case class OriginalMessageIdField(value: String) extends AnyVal
+
+case class ErrorField(value: String) extends AnyVal
+
+object IncludeOriginalMessageField {
+ def default: IncludeOriginalMessageField = IncludeOriginalMessageField(false)
+}
+
+case class IncludeOriginalMessageField(value: Boolean) extends AnyVal
+
+object MDNParsed {
+ def fromMDN(mdn: MDN, message: Message): MDNParsed = {
+ val report = mdn.getReport
+ MDNParsed(forEmailId = None,
+ subject = Option(message.getSubject).map(SubjectField),
+ textBody = Some(TextBodyField(mdn.getHumanReadableText)),
+ reportingUA = report.getReportingUserAgentField
+ .map(userAgent => ReportUAField(userAgent.fieldValue()))
+ .toScala,
+ finalRecipient = FinalRecipientField(report.getFinalRecipientField.fieldValue()),
+ originalMessageId = report.getOriginalMessageIdField
+ .map(originalMessageId => OriginalMessageIdField(originalMessageId.getOriginalMessageId))
+ .toScala,
+ originalRecipient = report.getOriginalRecipientField
+ .map(originalRecipient => OriginalRecipientField(originalRecipient.fieldValue()))
+ .toScala,
+ includeOriginalMessage = IncludeOriginalMessageField(mdn.getOriginalMessage.isPresent),
+ disposition = MDNDisposition.fromJava(report.getDispositionField),
+ error = Option(report.getErrorFields.asScala
+ .map(error => ErrorField(error.getText.formatted()))
+ .toSeq)
+ .filter(error => error.nonEmpty),
+ extensionFields = Option(report.getExtensionFields.asScala
+ .map(extension => (extension.getFieldName, extension.getRawValue))
+ .toMap).filter(_.nonEmpty))
+ }
+}
+
+case class MDNParsed(forEmailId: Option[ForEmailIdField],
+ subject: Option[SubjectField],
+ textBody: Option[TextBodyField],
+ reportingUA: Option[ReportUAField],
+ finalRecipient: FinalRecipientField,
+ originalMessageId: Option[OriginalMessageIdField],
+ originalRecipient: Option[OriginalRecipientField],
+ includeOriginalMessage: IncludeOriginalMessageField,
+ disposition: MDNDisposition,
+ error: Option[Seq[ErrorField]],
+ extensionFields: Option[Map[String, String]])
+
+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: 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((parsed1, parsed2) => parsed1 ++ parsed2),
+ notFound = (response1.notFound ++ response2.notFound).reduceOption((notFound1, notFound2) => notFound1.merge(notFound2)),
+ notParsable = (response1.notParsable ++ response2.notParsable).reduceOption((notParsable1, notParsable2) => notParsable1.merge(notParsable2)))
+}
+
+case class MDNParseResults(parsed: Option[Map[BlobId, MDNParsed]],
+ notFound: Option[MDNNotFound],
+ notParsable: Option[MDNNotParsable]) {
+ def asResponse(accountId: AccountId): MDNParseResponse = MDNParseResponse(accountId, parsed, notFound, notParsable)
+}
+
+case class MDNParseResponse(accountId: AccountId,
+ parsed: Option[Map[BlobId, MDNParsed]],
+ notFound: Option[MDNNotFound],
+ notParsable: Option[MDNNotParsable])
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
new file mode 100644
index 0000000..aba8406
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
@@ -0,0 +1,105 @@
+/****************************************************************
+ * 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_CORE, JMAP_MAIL, JMAP_MDN}
+import org.apache.james.jmap.core.Invocation
+import org.apache.james.jmap.core.Invocation._
+import org.apache.james.jmap.json.{MDNParseSerializer, ResponseSerializer}
+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 play.api.libs.json.{JsError, JsObject, JsSuccess, Json}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import java.io.InputStream
+import javax.inject.Inject
+import scala.util.Try
+
+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, JMAP_CORE)
+
+ 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, _) => emailGetRequest.validate
+ case errors: JsError => Left(new IllegalArgumentException(Json.stringify(ResponseSerializer.serialize(errors))))
+ }
+
+ 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 => toParseResults(blobId, mailboxSession))
+ .map(_.fold(e => MDNParseResults.notFound(e.blobId), result => result))
+
+ SFlux.merge(Seq(parsed, SFlux.fromIterable(invalid)))
+ .reduce(MDNParseResults.empty())(MDNParseResults.merge)
+ .map(_.asResponse(request.accountId))
+ }
+
+ private def toParseResults(blobId: BlobId, mailboxSession: MailboxSession): SMono[Either[BlobNotFoundException, MDNParseResults]] =
+ blobResolvers.resolve(blobId, mailboxSession)
+ .map(blob => Right(parse(blob.blobId, blob.content)))
+ .onErrorResume {
+ case e: BlobNotFoundException => SMono.just(Left(e))
+ }
+
+ private def parse(blobId: BlobId, blobContent: InputStream): MDNParseResults = {
+ val maybeMdn = for {
+ message <- Try(new DefaultMessageBuilder().parseMessage(blobContent))
+ mdn <- Try(MDN.parse(message))
+ jmapMdn = MDNParsed.fromMDN(mdn, message)
+ } yield {
+ MDNParseResults.parse(blobId, jmapMdn)
+ }
+
+ maybeMdn.fold(_ => MDNParseResults.notParse(blobId), result => result)
+ }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/Method.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/Method.scala
index a4acfe1..ab9b2fc 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/Method.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/Method.scala
@@ -22,7 +22,7 @@ import org.apache.james.jmap.api.exception.ChangeNotFoundException
import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
import org.apache.james.jmap.core.Invocation.MethodName
import org.apache.james.jmap.core.{AccountId, ErrorCode, Invocation, Session}
-import org.apache.james.jmap.mail.{UnsupportedFilterException, UnsupportedNestingException, UnsupportedRequestParameterException, UnsupportedSortException}
+import org.apache.james.jmap.mail.{RequestTooLargeException, UnsupportedFilterException, UnsupportedNestingException, UnsupportedRequestParameterException, UnsupportedSortException}
import org.apache.james.jmap.routes.{ProcessingContext, SessionSupplier}
import org.apache.james.mailbox.MailboxSession
import org.apache.james.mailbox.exception.MailboxNotFoundException
@@ -83,6 +83,7 @@ trait MethodRequiringAccountId[REQUEST <: WithAccountId] extends Method {
case e: IllegalArgumentException => SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(ErrorCode.InvalidArguments, e.getMessage, invocation.invocation.methodCallId), invocation.processingContext))
case e: MailboxNotFoundException => SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(ErrorCode.InvalidArguments, e.getMessage, invocation.invocation.methodCallId), invocation.processingContext))
case e: ChangeNotFoundException => SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(ErrorCode.CannotCalculateChanges, e.getMessage, invocation.invocation.methodCallId), invocation.processingContext))
+ case e: RequestTooLargeException => SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(ErrorCode.RequestTooLarge, e.description, invocation.invocation.methodCallId), invocation.processingContext))
case e: Throwable => SFlux.raiseError[InvocationWithContext] (e)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org