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