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/23 10:43:24 UTC

[james-project] branch master updated: JAMES-3520 JMAP - Implement MDN/send - RFC-9007 (#385)

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 a34c39d  JAMES-3520 JMAP - Implement MDN/send - RFC-9007 (#385)
a34c39d is described below

commit a34c39df8ca39ed58e4733a467381bcef5346147
Author: vttran <vt...@linagora.com>
AuthorDate: Fri Apr 23 17:43:14 2021 +0700

    JAMES-3520 JMAP - Implement MDN/send - RFC-9007 (#385)
---
 .../apache/james/mailbox/probe/MailboxProbe.java   |    5 +
 mdn/src/main/java/org/apache/james/mdn/MDN.java    |   18 +
 .../java/org/apache/james/mdn/fields/Gateway.java  |    7 +-
 .../org/apache/james/mdn/MDNReportParser.scala     |    4 +-
 .../test/java/org/apache/james/mdn/MDNTest.java    |   27 +
 .../org/apache/james/mdn/fields/GatewayTest.java   |   27 +-
 .../org/apache/james/modules/MailboxProbeImpl.java |   15 +
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   |   10 +
 .../distributed/DistributedMDNSendMethodTest.java  |   67 +
 .../james/jmap/rfc8621/contract/Fixture.scala      |    6 +
 .../rfc8621/contract/MDNSendMethodContract.scala   | 1906 ++++++++++++++++++++
 .../rfc8621/memory/MemoryMDNSendMethodTest.java    |   56 +-
 .../org/apache/james/jmap/core/Properties.scala    |    6 +
 .../org/apache/james/jmap/core/SetError.scala      |    8 +
 .../james/jmap/json/MDNParseSerializer.scala       |   54 -
 .../org/apache/james/jmap/json/MDNSerializer.scala |   94 +
 .../scala/org/apache/james/jmap/mail/MDN.scala     |  135 ++
 .../org/apache/james/jmap/mail/MDNParse.scala      |   35 -
 .../scala/org/apache/james/jmap/mail/MDNSend.scala |  247 +++
 .../james/jmap/method/IdentityGetMethod.scala      |   11 +-
 .../apache/james/jmap/method/MDNParseMethod.scala  |   11 +-
 .../apache/james/jmap/method/MDNSendMethod.scala   |  312 ++++
 .../org/apache/james/jmap/method/Method.scala      |    3 +-
 .../james/jmap/json/MDNSerializationTest.scala     |  275 +++
 24 files changed, 3208 insertions(+), 131 deletions(-)

diff --git a/mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java b/mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java
index 1aefa88..a7115e8 100644
--- a/mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java
+++ b/mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java
@@ -29,6 +29,8 @@ import org.apache.james.mailbox.exception.MailboxException;
 import org.apache.james.mailbox.model.ComposedMessageId;
 import org.apache.james.mailbox.model.MailboxId;
 import org.apache.james.mailbox.model.MailboxPath;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.mailbox.model.MultimailboxesSearchQuery;
 
 public interface MailboxProbe {
 
@@ -44,4 +46,7 @@ public interface MailboxProbe {
             boolean isRecent, Flags flags) throws MailboxException;
 
     Collection<String> listSubscriptions(String user) throws Exception;
+
+    Collection<MessageId> searchMessage(MultimailboxesSearchQuery expression, String user, long limit);
+
 }
\ No newline at end of file
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 b939d65..845f3aa 100644
--- a/mdn/src/main/java/org/apache/james/mdn/MDN.java
+++ b/mdn/src/main/java/org/apache/james/mdn/MDN.java
@@ -27,12 +27,14 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.Properties;
 
+import javax.activation.DataHandler;
 import javax.mail.BodyPart;
 import javax.mail.MessagingException;
 import javax.mail.Session;
 import javax.mail.internet.MimeBodyPart;
 import javax.mail.internet.MimeMessage;
 import javax.mail.internet.MimeMultipart;
+import javax.mail.util.ByteArrayDataSource;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.james.javax.MimeMultipartReport;
@@ -43,9 +45,11 @@ 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.DefaultMessageWriter;
 import org.apache.james.mime4j.message.MultipartBuilder;
 import org.apache.james.mime4j.stream.NameValuePair;
 
+import com.github.fge.lambdas.Throwing;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Preconditions;
 
@@ -205,6 +209,9 @@ public class MDN {
         multipart.setReportType(DISPOSITION_NOTIFICATION_REPORT_TYPE);
         multipart.addBodyPart(computeHumanReadablePart());
         multipart.addBodyPart(computeReportPart());
+        message.ifPresent(Throwing.consumer(originalMessage -> multipart.addBodyPart(computeOriginalMessagePart((Message) originalMessage)))
+                .sneakyThrow());
+
         // The optional third part, the original message is omitted.
         // We don't want to propogate over-sized, virus infected or
         // other undesirable mail!
@@ -233,6 +240,17 @@ public class MDN {
         return mdnPart;
     }
 
+    public BodyPart computeOriginalMessagePart(Message message) throws MessagingException {
+        MimeBodyPart originalMessagePart = new MimeBodyPart();
+        try {
+            ByteArrayDataSource source = new ByteArrayDataSource(DefaultMessageWriter.asBytes(message), "message/rfc822");
+            originalMessagePart.setDataHandler(new DataHandler(source));
+            return originalMessagePart;
+        } catch (IOException e) {
+            throw new MessagingException("Could not write message as bytes", e);
+        }
+    }
+
     public Message.Builder asMime4JMessageBuilder() throws IOException {
         Message.Builder messageBuilder = Message.Builder.of();
         messageBuilder.setBody(asMime4JMultipart());
diff --git a/mdn/src/main/java/org/apache/james/mdn/fields/Gateway.java b/mdn/src/main/java/org/apache/james/mdn/fields/Gateway.java
index 9699fc6..562294b 100644
--- a/mdn/src/main/java/org/apache/james/mdn/fields/Gateway.java
+++ b/mdn/src/main/java/org/apache/james/mdn/fields/Gateway.java
@@ -22,6 +22,7 @@ package org.apache.james.mdn.fields;
 import java.util.Objects;
 import java.util.Optional;
 
+import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
 
 /**
@@ -69,7 +70,11 @@ public class Gateway implements Field {
 
     @Override
     public String formattedValue() {
-        return FIELD_NAME + ": " + nameType.getType() + ";" + name.formatted();
+        return FIELD_NAME + ": " + fieldValue();
+    }
+
+    public String fieldValue() {
+        return Joiner.on(";").skipNulls().join(nameType.getType(), name.formatted());
     }
 
     public AddressType getNameType() {
diff --git a/mdn/src/main/scala/org/apache/james/mdn/MDNReportParser.scala b/mdn/src/main/scala/org/apache/james/mdn/MDNReportParser.scala
index ea50183..f7f0bdc 100644
--- a/mdn/src/main/scala/org/apache/james/mdn/MDNReportParser.scala
+++ b/mdn/src/main/scala/org/apache/james/mdn/MDNReportParser.scala
@@ -96,7 +96,7 @@ class MDNReportParser(val input: ParserInput) extends Parser {
 
   /*    reporting-ua-field = "Reporting-UA" ":" OWS ua-name OWS [
                                    ";" OWS ua-product OWS ]    */
-  private[mdn] def reportingUaField: Rule1[ReportingUserAgent] = rule {
+  def reportingUaField: Rule1[ReportingUserAgent] = rule {
     ("Reporting-UA" ~ ":" ~ ows ~ capture(uaName) ~ ows ~ (";" ~ ows ~ capture(uaProduct) ~ ows).?) ~> ((uaName: String, uaProduct: Option[String]) => {
      val builder = ReportingUserAgent.builder()
         .userAgentName(uaName)
@@ -184,7 +184,7 @@ class MDNReportParser(val input: ParserInput) extends Parser {
   /*    final-recipient-field =
              "Final-Recipient" ":" OWS address-type OWS
              ";" OWS generic-address OWS    */
-  private[mdn] def finalRecipientField : Rule1[FinalRecipient] = rule {
+  def finalRecipientField : Rule1[FinalRecipient] = rule {
     ("Final-Recipient" ~ ":" ~ ows ~ capture(addressType) ~ ows ~ ";" ~ ows ~ capture(genericAddress) ~ ows) ~> ((addrType : String, genericAddr : String) =>
     FinalRecipient
       .builder()
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 0292e74..bda884b 100644
--- a/mdn/src/test/java/org/apache/james/mdn/MDNTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/MDNTest.java
@@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.io.ByteArrayOutputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.Optional;
 import java.util.regex.Pattern;
 
 import javax.mail.internet.MimeMessage;
@@ -419,6 +420,32 @@ class MDNTest {
         assertThat(mdnActual.getOriginalMessage()).isPresent();
     }
 
+    @Test
+    public void originalMessageShouldBeContainInMimeMessage() throws Exception {
+        MDN mdn = MDN.builder()
+            .humanReadableText("humanReadableText")
+            .report(MINIMAL_REPORT)
+            .message(Optional.of(Message.Builder
+                .of()
+                .setSubject("Subject of original message$tag")
+                .setBody("Body of message", StandardCharsets.UTF_8)
+                .build()))
+            .build();
+        MimeMessage mimeMessage = mdn.asMimeMessage();
+
+        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+        mimeMessage.writeTo(byteArrayOutputStream);
+
+        assertThat(byteArrayOutputStream.toString(StandardCharsets.UTF_8))
+            .contains("Content-Type: message/rfc822\r\n" +
+                "\r\n" +
+                "MIME-Version: 1.0\r\n" +
+                "Subject: Subject of original message$tag\r\n" +
+                "Content-Type: text/plain; charset=UTF-8\r\n" +
+                "\r\n" +
+                "Body of message");
+    }
+
     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/GatewayTest.java b/mdn/src/test/java/org/apache/james/mdn/fields/GatewayTest.java
index 518b239..1d2a8c8 100644
--- a/mdn/src/test/java/org/apache/james/mdn/fields/GatewayTest.java
+++ b/mdn/src/test/java/org/apache/james/mdn/fields/GatewayTest.java
@@ -19,12 +19,11 @@
 
 package org.apache.james.mdn.fields;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
+import nl.jqno.equalsverifier.EqualsVerifier;
 import org.junit.jupiter.api.Test;
 
-import nl.jqno.equalsverifier.EqualsVerifier;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class GatewayTest {
     @Test
@@ -86,4 +85,24 @@ class GatewayTest {
             .formattedValue())
             .isEqualTo("MDN-Gateway: custom;address");
     }
+
+    @Test
+    void fieldValueShouldDisplayAddress() {
+        assertThat(Gateway.builder()
+                .name(Text.fromRawText("address"))
+                .build()
+                .fieldValue())
+                .isEqualTo("dns;address");
+    }
+
+    @Test
+    void fieldValueShouldDisplayMultilineAddress() {
+        assertThat(Gateway.builder()
+                .nameType(AddressType.UNKNOWN)
+                .name(Text.fromRawText("address\nmultiline"))
+                .build()
+                .fieldValue())
+                .isEqualTo("unknown;address\r\n multiline");
+    }
+
 }
diff --git a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java
index 13dd00a..10755c1 100644
--- a/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java
+++ b/server/container/guice/mailbox/src/main/java/org/apache/james/modules/MailboxProbeImpl.java
@@ -42,6 +42,8 @@ import org.apache.james.mailbox.model.ComposedMessageId;
 import org.apache.james.mailbox.model.MailboxId;
 import org.apache.james.mailbox.model.MailboxMetaData;
 import org.apache.james.mailbox.model.MailboxPath;
+import org.apache.james.mailbox.model.MessageId;
+import org.apache.james.mailbox.model.MultimailboxesSearchQuery;
 import org.apache.james.mailbox.model.search.MailboxQuery;
 import org.apache.james.mailbox.model.search.Wildcard;
 import org.apache.james.mailbox.probe.MailboxProbe;
@@ -176,4 +178,17 @@ public class MailboxProbeImpl implements GuiceProbe, MailboxProbe {
         MailboxSession mailboxSession = mailboxManager.createSystemSession(Username.of(user));
         return subscriptionManager.subscriptions(mailboxSession);
     }
+
+    @Override
+    public Collection<MessageId> searchMessage(MultimailboxesSearchQuery expression, String user, long limit) {
+        MailboxSession mailboxSession = null;
+        try {
+            mailboxSession = mailboxManager.createSystemSession(Username.of(user));
+            return block(Flux.from(mailboxManager.search(expression, mailboxSession, limit)).collectList());
+        } catch (MailboxException e) {
+            throw new RuntimeException(e);
+        } finally {
+            closeSession(mailboxSession);
+        }
+    }
 }
\ No newline at end of file
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 08dd000..3d428c2 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
@@ -44,6 +44,7 @@ import org.apache.james.jmap.method.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.MDNSendMethod;
 import org.apache.james.jmap.method.MailboxChangesMethod;
 import org.apache.james.jmap.method.MailboxGetMethod;
 import org.apache.james.jmap.method.MailboxQueryMethod;
@@ -84,6 +85,7 @@ public class RFC8621MethodsModule extends AbstractModule {
         bind(ZoneIdProvider.class).to(SystemZoneIdProvider.class);
 
         bind(EmailSubmissionSetMethod.class).in(Scopes.SINGLETON);
+        bind(MDNSendMethod.class).in(Scopes.SINGLETON);
 
         Multibinder<Method> methods = Multibinder.newSetBinder(binder(), Method.class);
         methods.addBinding().to(CoreEchoMethod.class);
@@ -99,6 +101,7 @@ public class RFC8621MethodsModule extends AbstractModule {
         methods.addBinding().to(MailboxQueryMethod.class);
         methods.addBinding().to(MailboxSetMethod.class);
         methods.addBinding().to(MDNParseMethod.class);
+        methods.addBinding().to(MDNSendMethod.class);
         methods.addBinding().to(ThreadChangesMethod.class);
         methods.addBinding().to(ThreadGetMethod.class);
         methods.addBinding().to(VacationResponseGetMethod.class);
@@ -151,4 +154,11 @@ public class RFC8621MethodsModule extends AbstractModule {
                 .forClass(EmailSubmissionSetMethod.class)
                 .init(instance::init);
     }
+
+    @ProvidesIntoSet
+    InitializationOperation initMDNSends(MDNSendMethod instance) {
+        return InitilizationOperationBuilder
+            .forClass(MDNSendMethod.class)
+            .init(instance::init);
+    }
 }
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/DistributedMDNSendMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/distributed/DistributedMDNSendMethodTest.java
new file mode 100644
index 0000000..8c5c9da
--- /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/DistributedMDNSendMethodTest.java
@@ -0,0 +1,67 @@
+/****************************************************************
+ * 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.EmailSubmissionSetMethodContract;
+import org.apache.james.jmap.rfc8621.contract.MDNSendMethodContract;
+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;
+
+class DistributedMDNSendMethodTest implements MDNSendMethodContract {
+    public static final DockerElasticSearchExtension ELASTIC_SEARCH_EXTENSION = new DockerElasticSearchExtension();
+    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(ELASTIC_SEARCH_EXTENSION)
+        .extension(new CassandraExtension())
+        .extension(new RabbitMQExtension())
+        .extension(new AwsS3BlobStoreExtension())
+        .server(configuration -> CassandraRabbitMQJamesServerMain.createServer(configuration)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+
+    @Override
+    public MessageId randomMessageId() {
+        return MESSAGE_ID_FACTORY.of(UUIDs.timeBased());
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala
index 73bb287..88fc460 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/Fixture.scala
@@ -41,6 +41,12 @@ object Fixture {
   val ACCOUNT_ID: String = "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
   val ALICE_ACCOUNT_ID: String = "2bd806c97f0e00af1a1fc3328fa763a9269723c8db8fac4f93af71db186d6e90"
   val ANDRE_ACCOUNT_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
+  val DAVID_ACCOUNT_ID: String = "a63dc794489dca3a428ae19c0632425619aa2d8551cd8dab26f4b9a87c774342"
+
+  val IDENTITY_ID: String = "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6"
+  val DAVID_IDENTITY_ID: String = "a63dc794489dca3a428ae19c0632425619aa2d8551cd8dab26f4b9a87c774342"
+  val ANDRE_IDENTITY_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
+
 
   def createTestMessage: Message = Message.Builder
       .of
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/MDNSendMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNSendMethodContract.scala
new file mode 100644
index 0000000..2a10179
--- /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/MDNSendMethodContract.scala
@@ -0,0 +1,1906 @@
+/****************************************************************
+ * 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 io.restassured.specification.RequestSpecification
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.core.Username
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.draft.MessageIdProbe
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder, _}
+import org.apache.james.jmap.rfc8621.contract.MDNSendMethodContract.TAG_MDN_MESSAGE_FORMAT
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxId, MailboxPath, MessageId, MultimailboxesSearchQuery, SearchQuery}
+import org.apache.james.mime4j.codec.DecodeMonitor
+import org.apache.james.mime4j.dom.{Message, Multipart}
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.mime4j.stream.{MimeConfig, RawField}
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.awaitility.core.ConditionFactory
+import org.junit.jupiter.api.{BeforeEach, Tag, Test}
+
+import java.nio.charset.StandardCharsets
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+import scala.jdk.CollectionConverters._
+
+object MDNSendMethodContract {
+  val TAG_MDN_MESSAGE_FORMAT: "MDN_MESSAGE_FORMAT" = "MDN_MESSAGE_FORMAT"
+}
+
+trait MDNSendMethodContract {
+  private lazy val slowPacedPollInterval: Duration = ONE_HUNDRED_MILLISECONDS
+
+  private lazy val calmlyAwait: ConditionFactory = Awaitility.`with`
+    .pollInterval(slowPacedPollInterval)
+    .and.`with`.pollDelay(slowPacedPollInterval)
+    .await
+
+  private lazy val awaitAtMostTenSeconds: ConditionFactory = calmlyAwait.atMost(10, TimeUnit.SECONDS)
+
+  private def getFirstMessageInMailBox(guiceJamesServer: GuiceJamesServer, username: Username): Option[Message] = {
+    val searchByRFC822MessageId: MultimailboxesSearchQuery = MultimailboxesSearchQuery.from(SearchQuery.of(SearchQuery.all())).build
+    val defaultMessageBuilder: DefaultMessageBuilder = new DefaultMessageBuilder
+    defaultMessageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE)
+    defaultMessageBuilder.setDecodeMonitor(DecodeMonitor.SILENT)
+
+    guiceJamesServer.getProbe(classOf[MailboxProbeImpl]).searchMessage(searchByRFC822MessageId, username.asString(), 100)
+      .asScala.headOption
+      .flatMap(messageId => guiceJamesServer.getProbe(classOf[MessageIdProbe]).getMessages(messageId, username).asScala.headOption)
+      .map(messageResult => defaultMessageBuilder.parseMessage(messageResult.getFullContent.getInputStream))
+  }
+
+  private def buildOriginalMessage(tag : String) :Message =
+    Message.Builder
+      .of
+      .setSubject(s"Subject of original message$tag")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .addField(new RawField("Disposition-Notification-To", s"Bob <${BOB.asString()}>"))
+      .setBody(s"Body of mail$tag, that mdn related", StandardCharsets.UTF_8)
+      .build
+
+  private def buildBOBRequestSpecification(server: GuiceJamesServer): RequestSpecification =
+    baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .build
+
+  def randomMessageId: MessageId
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+      .addUser(ANDRE.asString, ANDRE_PASSWORD)
+      .addUser(DAVID.asString, DAVID.asString())
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+      .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    val bobInboxId: MailboxId = mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            },
+         |            "extensionFields": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+   val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "MDN/send",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "sent": {
+           |                    "k1546": {
+           |                        "finalRecipient": "rfc822; ${ANDRE.asString}",
+           |                        "includeOriginalMessage": false,
+           |                        "originalRecipient": "rfc822; ${ANDRE.asString()}"
+           |                    }
+           |                }
+           |            },
+           |            "c1"
+           |        ],
+           |        [
+           |            "Email/set",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "oldState": "23",
+           |                "newState": "42",
+           |                "updated": {
+           |                    "${relatedEmailId.serialize()}": null
+           |                }
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+
+    val requestQueryMDNMessage: String =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/query",
+         |    {
+         |      "accountId": "$ACCOUNT_ID",
+         |      "filter": {"inMailbox": "${bobInboxId.serialize}"}
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      val response: String =
+        `given`(buildBOBRequestSpecification(server))
+          .body(requestQueryMDNMessage)
+        .when
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+    server.getProbe(classOf[DataProbeImpl]).addUserAliasMapping("david", "domain.tld", "andre@domain.tld")
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$DAVID_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "finalRecipient": "rfc822; ${DAVID.asString()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "MDN/send",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "sent": {
+           |                    "k1546": {
+           |                        "includeOriginalMessage": false,
+           |                        "originalRecipient": "rfc822; ${ANDRE.asString()}"
+           |                    }
+           |                }
+           |            },
+           |            "c1"
+           |        ],
+           |        [
+           |            "Email/set",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "oldState": "23",
+           |                "newState": "42",
+           |                "updated": {
+           |                    "${relatedEmailId.serialize()}": null
+           |                }
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenDispositionPropertyIsInvalid(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "disposition": {
+         |              "actionMode": "invalidAction",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "invalidArguments",
+                   |        "description": "Disposition \"ActionMode\" is invalid.",
+                   |        "properties":["disposition"]
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenFinalRecipientIsInvalid(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "finalRecipient" : "invalid",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "invalidArguments",
+                   |        "description": "FinalRecipient can't be parse.",
+                   |        "properties": [
+                   |            "finalRecipient"
+                   |        ]
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenIdentityIsNotAllowedToUseFinalRecipient(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "finalRecipient" : "rfc822; ${CEDRIC.asString}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "forbiddenFrom",
+                   |        "description": "The user is not allowed to use the given \"finalRecipient\" property"
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenMDNIsNotSent(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "finalRecipient" : "invalid",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "MDN/send",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "sent": {
+           |                    "k1546": {
+           |                        "finalRecipient": "rfc822; ${ANDRE.asString}",
+           |                        "includeOriginalMessage": false,
+           |                        "originalRecipient": "rfc822; ${ANDRE.asString}"
+           |                    }
+           |                }
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldAcceptSeveralMDNObjects(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId1: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+    val relatedEmailId2: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("2")))
+      .getMessageId
+    val relatedEmailId3: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("3")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId1.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          },
+         |          "k1547": {
+         |            "forEmailId": "${relatedEmailId2.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          },
+         |          "k1548": {
+         |            "forEmailId": "${relatedEmailId3.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          },
+         |          "#k1547": {
+         |            "keywords/$$mdnsent": true
+         |          },
+         |          "#k1548": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+   val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(
+        s"""{
+           |	"sessionState": "${SESSION_STATE.value}",
+           |	"methodResponses": [
+           |		[
+           |			"MDN/send",
+           |			{
+           |				"accountId": "$ANDRE_ACCOUNT_ID",
+           |				"sent": {
+           |					"k1546": {
+           |						"subject": "[Received] Subject of original message1",
+           |						"textBody": "The email has been displayed on your recipient's computer",
+           |						"originalRecipient": "rfc822; ${ANDRE.asString()}",
+           |						"finalRecipient": "rfc822; ${ANDRE.asString()}",
+           |						"includeOriginalMessage": false
+           |					},
+           |					"k1547": {
+           |						"subject": "[Received] Subject of original message2",
+           |						"textBody": "The email has been displayed on your recipient's computer",
+           |						"originalRecipient": "rfc822; ${ANDRE.asString()}",
+           |						"finalRecipient": "rfc822; ${ANDRE.asString()}",
+           |						"includeOriginalMessage": false
+           |					},
+           |					"k1548": {
+           |						"subject": "[Received] Subject of original message3",
+           |						"textBody": "The email has been displayed on your recipient's computer",
+           |						"originalRecipient": "rfc822; ${ANDRE.asString()}",
+           |						"finalRecipient": "rfc822; ${ANDRE.asString()}",
+           |						"includeOriginalMessage": false
+           |					}
+           |				}
+           |			},
+           |			"c1"
+           |		],
+           |		[
+           |			"Email/set",
+           |			{
+           |				"accountId": "$ANDRE_ACCOUNT_ID",
+           |				"oldState": "3be4a1bc-0b41-4e33-aaf0-585e567a5af5",
+           |				"newState": "3e1d5c70-9ca4-4c02-a35c-f54a51d253e3",
+           |				"updated": {
+           |					"${relatedEmailId1.serialize()}": null,
+           |					"${relatedEmailId2.serialize()}": null,
+           |					"${relatedEmailId3.serialize()}": null
+           |				}
+           |			},
+           |			"c1"
+           |		]
+           |	]
+           |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendMixValidAndNotFoundAndInvalid(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val validEmailId1: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+    val validEmailId2: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("2")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${validEmailId1.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          },
+         |          "k1547": {
+         |            "forEmailId": "${validEmailId2.serialize()}",
+         |            "badProperty": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          },
+         |          "k1548": {
+         |            "forEmailId": "${randomMessageId.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          },
+         |          "#k1547": {
+         |            "keywords/$$mdnsent": true
+         |          },
+         |          "#k1548": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+   val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(s"""{
+                   |    "sessionState": "${SESSION_STATE.value}",
+                   |    "methodResponses": [
+                   |        [
+                   |            "MDN/send",
+                   |            {
+                   |                "accountId": "$ANDRE_ACCOUNT_ID",
+                   |                "sent": {
+                   |                    "k1546": {
+                   |                        "subject": "[Received] Subject of original message1",
+                   |                        "textBody": "The email has been displayed on your recipient's computer",
+                   |                        "originalRecipient": "rfc822; ${ANDRE.asString()}",
+                   |                        "finalRecipient": "rfc822; ${ANDRE.asString()}",
+                   |                        "includeOriginalMessage": false
+                   |                    }
+                   |                },
+                   |                "notSent": {
+                   |                    "k1547": {
+                   |                        "type": "invalidArguments",
+                   |                        "description": "Some unknown properties were specified",
+                   |                        "properties": [
+                   |                            "badProperty"
+                   |                        ]
+                   |                    },
+                   |                    "k1548": {
+                   |                        "type": "notFound",
+                   |                        "description": "The reference \\"forEmailId\\" cannot be found."
+                   |                    }
+                   |                }
+                   |            },
+                   |            "c1"
+                   |        ],
+                   |        [
+                   |            "Email/set",
+                   |            {
+                   |                "accountId": "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c",
+                   |                "oldState": "eda83b09-6aca-4215-b493-2b4af19c50f0",
+                   |                "newState": "8bd671b2-e9fd-4ce3-b9b2-c3e1f35cc8ee",
+                   |                "updated": {
+                   |                    "${validEmailId1.serialize()}": null
+                   |                }
+                   |            },
+                   |            "c1"
+                   |        ]
+                   |    ]
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenMDNHasAlreadyBeenSet(server: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ACCOUNT_ID",
+         |        "identityId": "$IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+      `given`(buildBOBRequestSpecification(server))
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+
+    val response: String =
+      `given`(buildBOBRequestSpecification(server))
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "mdnAlreadySent",
+                   |        "description": "The message has the $mdnsent keyword already set."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingOneCapability(server: GuiceJamesServer): Unit = {
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`
+        .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 mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(server: GuiceJamesServer): Unit = {
+    val request: String =
+      s"""{
+         |  "using": [],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`(buildBOBRequestSpecification(server))
+        .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)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(server: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ACCOUNT_ID",
+         |        "identityId": "$IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${randomMessageId.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`(buildBOBRequestSpecification(server))
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("Body of mail, that mdn related", StandardCharsets.UTF_8)
+      .build
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                    |    "k1546": {
+                    |        "type": "notFound",
+                    |        "description": "Invalid \"Disposition-Notification-To\" header field."
+                    |    }
+                    |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnInvalidWhenIdentityDoesNotExist(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("Body of mail, that mdn related", StandardCharsets.UTF_8)
+      .build
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "notFound",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response)
+      .isEqualTo(s"""{
+                   |    "sessionState": "${SESSION_STATE.value}",
+                   |    "methodResponses": [
+                   |        [
+                   |            "error",
+                   |            {
+                   |                "type": "invalidArguments",
+                   |                "description": "The IdentityId cannot be found"
+                   |            },
+                   |            "c1"
+                   |        ]
+                   |    ]
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenWrongAccountId(server: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "unknownAccountId",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`(buildBOBRequestSpecification(server))
+        .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 mdnSendShouldBeFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(server: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "notStored": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`(buildBOBRequestSpecification(server))
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailDoesNotReferenceACreationWithinThisCall(server: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#notReference": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response: String =
+      `given`(buildBOBRequestSpecification(server))
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "error",
+           |            {
+           |                "type": "invalidArguments",
+           |                "description": "#notReference cannot be referenced in current method call"
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)
+  @Test
+  def mdnSendShouldReturnSubjectWhenRequestDoNotSet(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[0][1].sent.k1546.subject")
+      .asString().isEqualTo("[Received] Subject of original message1")
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)
+  @Test
+  def mdnSendShouldReturnTextBodyWhenRequestDoNotSet(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[0][1].sent.k1546.textBody")
+      .asString().isEqualTo("The email has been displayed on your recipient's computer")
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)
+  @Test
+  def mdnSendShouldReturnOriginalMessageIdWhenRelatedMessageHasMessageIDHeader(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(Message.Builder
+          .of
+          .setSubject(s"Subject of original message")
+          .setSender(BOB.asString)
+          .setFrom(BOB.asString)
+          .setTo(ANDRE.asString)
+          .addField(new RawField("Disposition-Notification-To", s"Bob <${BOB.asString()}>"))
+          .addField(new RawField("Message-Id", "<19...@example.org>"))
+          .setBody(s"Body of mail, that mdn related", StandardCharsets.UTF_8)
+          .build
+        ))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val mdnSendResponse: String =
+      `given`
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[0][1].sent.k1546.originalMessageId")
+      .asString().isEqualTo("<19...@example.org>")
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)
+  @Test
+  def mdnMessageShouldHasThirdBodyPartWhenIncludeOriginalMessageIsTrue(server: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = server.getProbe(classOf[MailboxProbeImpl])
+
+    val bobMailBoxPath: MailboxPath = MailboxPath.inbox(BOB)
+    mailboxProbe.createMailbox(bobMailBoxPath)
+
+    val andreMailBoxPath: MailboxPath = MailboxPath.inbox(ANDRE)
+    mailboxProbe.createMailbox(andreMailBoxPath)
+
+    val relatedEmailId: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val request: String =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "$ANDRE_ACCOUNT_ID",
+         |        "identityId": "$ANDRE_IDENTITY_ID",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${relatedEmailId.serialize()}",
+         |            "subject": "Read receipt for: World domination",
+         |            "textBody": "This receipt shows that the email has been displayed on your recipient's computer. ",
+         |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+         |            "disposition": {
+         |              "actionMode": "manual-action",
+         |              "sendingMode": "mdn-sent-manually",
+         |              "type": "displayed"
+         |            },
+         |            "includeOriginalMessage": true,
+         |            "extensionFields": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    `given`
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      val mdnBodyPartCounter = getFirstMessageInMailBox(server, BOB)
+        .filter(msg => msg.isMultipart)
+        .map(msg => msg.getBody.asInstanceOf[Multipart].getBodyParts)
+      assert(mdnBodyPartCounter.isDefined && mdnBodyPartCounter.get.size == 3)
+    }
+  }
+}
diff --git a/mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMDNSendMethodTest.java
similarity index 50%
copy from mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java
copy to server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMDNSendMethodTest.java
index 1aefa88..bf475c0 100644
--- a/mailbox/api/src/main/java/org/apache/james/mailbox/probe/MailboxProbe.java
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryMDNSendMethodTest.java
@@ -17,31 +17,31 @@
  * under the License.                                           *
  ****************************************************************/
 
-package org.apache.james.mailbox.probe;
-
-import java.io.InputStream;
-import java.util.Collection;
-import java.util.Date;
-
-import javax.mail.Flags;
-
-import org.apache.james.mailbox.exception.MailboxException;
-import org.apache.james.mailbox.model.ComposedMessageId;
-import org.apache.james.mailbox.model.MailboxId;
-import org.apache.james.mailbox.model.MailboxPath;
-
-public interface MailboxProbe {
-
-    MailboxId createMailbox(String namespace, String user, String name);
-
-    MailboxId getMailboxId(String namespace, String user, String name);
-
-    Collection<String> listUserMailboxes(String user);
-
-    void deleteMailbox(String namespace, String user, String name);
-
-    ComposedMessageId appendMessage(String username, MailboxPath mailboxPath, InputStream message, Date internalDate,
-            boolean isRecent, Flags flags) throws MailboxException;
-
-    Collection<String> listSubscriptions(String user) throws Exception;
-}
\ No newline at end of file
+package org.apache.james.jmap.rfc8621.memory;
+
+import static org.apache.james.MemoryJamesServerMain.IN_MEMORY_SERVER_AGGREGATE_MODULE;
+
+import org.apache.james.GuiceJamesServer;
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.jmap.rfc8621.contract.MDNSendMethodContract;
+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 java.util.concurrent.ThreadLocalRandom;
+
+public class MemoryMDNSendMethodTest implements MDNSendMethodContract {
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<>(JamesServerBuilder.defaultConfigurationProvider())
+        .server(configuration -> GuiceJamesServer.forConfiguration(configuration)
+            .combineWith(IN_MEMORY_SERVER_AGGREGATE_MODULE)
+            .overrideWith(new TestJMAPServerModule()))
+        .build();
+
+    @Override
+    public MessageId randomMessageId() {
+        return InMemoryMessageId.of(ThreadLocalRandom.current().nextInt(100000) + 100);
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Properties.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Properties.scala
index 90f4627..b182225 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Properties.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Properties.scala
@@ -28,6 +28,12 @@ object Properties {
   def empty(): Properties = Properties()
 
   def apply(values: NonEmptyString*): Properties = Properties(values.toSet)
+
+  def toProperties(strings: Set[String]): Properties = Properties(strings
+    .flatMap(string => {
+      val refinedValue: Either[String, NonEmptyString] = refineV[NonEmpty](string)
+      refinedValue.fold(_ => None, Some(_))
+    }))
 }
 
 case class Properties(value: Set[NonEmptyString]) {
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/SetError.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/SetError.scala
index 685578d..8947e8a 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/SetError.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/SetError.scala
@@ -34,6 +34,8 @@ object SetError {
   val notFoundValue: SetErrorType = "notFound"
   val forbiddenValue: SetErrorType = "forbidden"
   val stateMismatchValue: SetErrorType = "stateMismatch"
+  val mdnAlreadySentValue: SetErrorType = "mdnAlreadySent"
+  val forbiddenFromValue: SetErrorType = "forbiddenFrom"
 
   def invalidArguments(description: SetErrorDescription, properties: Option[Properties] = None): SetError =
     SetError(invalidArgumentValue, description, properties)
@@ -52,6 +54,12 @@ object SetError {
 
   def stateMismatch(description: SetErrorDescription, properties: Properties): SetError =
     SetError(stateMismatchValue, description, Some(properties))
+
+  def mdnAlreadySent(description: SetErrorDescription): SetError =
+    SetError(SetError.mdnAlreadySentValue,description, None)
+
+  def forbiddenFrom(description: SetErrorDescription): SetError =
+    SetError(SetError.forbiddenFromValue,description, None)
 }
 
 case class SetError(`type`: SetErrorType, description: SetErrorDescription, properties: Option[Properties])
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
deleted file mode 100644
index 80670a6..0000000
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNParseSerializer.scala
+++ /dev/null
@@ -1,54 +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.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 org.apache.james.mailbox.model.MessageId
-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 messageIdWrites: Writes[MessageId] = id => JsString(id.serialize())
-  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/json/MDNSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNSerializer.scala
new file mode 100644
index 0000000..b1cdc02
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNSerializer.scala
@@ -0,0 +1,94 @@
+/****************************************************************
+ * 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.core.{Id, SetError}
+import org.apache.james.jmap.mail.{BlobId, BlobIds, ErrorField, ExtensionFieldName, ExtensionFieldValue, FinalRecipientField, ForEmailIdField, IdentityId, IncludeOriginalMessageField, MDNDisposition, MDNGatewayField, MDNNotFound, MDNNotParsable, MDNParseRequest, MDNParseResponse, MDNParsed, MDNSendCreateRequest, MDNSendCreateResponse, MDNSendCreationId, MDNSendRequest, MDNSendResponse, OriginalMessageIdField, OriginalRecipientField, ReportUAField, SubjectField, TextBodyField}
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json._
+
+import javax.inject.Inject
+import scala.util.Try
+
+class MDNSerializer @Inject()(messageIdFactory: MessageId.Factory) {
+
+  private implicit val messageIdReads: Reads[MessageId] = {
+    case JsString(serializedMessageId) => Try(JsSuccess(messageIdFactory.fromString(serializedMessageId)))
+      .fold(_ => JsError("Invalid messageId"), messageId => messageId)
+    case _ => JsError("Expecting messageId to be represented by a JsString")
+  }
+  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 messageIdWrites: Writes[MessageId] = id => JsString(id.serialize())
+  private implicit val forEmailIdFormat: Format[ForEmailIdField] = Json.valueFormat[ForEmailIdField]
+  private implicit val subjectFieldFormat: Format[SubjectField] = Json.valueFormat[SubjectField]
+  private implicit val textBodyFieldFormat: Format[TextBodyField] = Json.valueFormat[TextBodyField]
+  private implicit val reportUAFieldFormat: Format[ReportUAField] = Json.valueFormat[ReportUAField]
+  private implicit val finalRecipientFieldFormat: Format[FinalRecipientField] = Json.valueFormat[FinalRecipientField]
+  private implicit val originalMessageIdFieldFormat: Format[OriginalMessageIdField] = Json.valueFormat[OriginalMessageIdField]
+  private implicit val originalRecipientFieldFormat: Format[OriginalRecipientField] = Json.valueFormat[OriginalRecipientField]
+  private implicit val includeOriginalMessageFieldFormat: Format[IncludeOriginalMessageField] = Json.valueFormat[IncludeOriginalMessageField]
+  private implicit val mdnGatewayFieldFormat: Format[MDNGatewayField] = Json.valueFormat[MDNGatewayField]
+  private implicit val mdnDispositionFormat: Format[MDNDisposition] = Json.format[MDNDisposition]
+  private implicit val identityIdFormat: Format[IdentityId] = Json.valueFormat[IdentityId]
+  private implicit val mdnErrorFieldReads: Reads[ErrorField] = Json.reads[ErrorField]
+  private implicit val mdnErrorFieldWrites: Writes[ErrorField] = Json.valueWrites[ErrorField]
+  private implicit val extensionFieldNameFormat: Format[ExtensionFieldName] = Json.valueFormat[ExtensionFieldName]
+  private implicit val extensionFieldValueFormat: Format[ExtensionFieldValue] = Json.valueFormat[ExtensionFieldValue]
+  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]
+
+  private implicit val mapMDNSendIDAndMDNReads: Reads[Map[MDNSendCreationId, JsObject]] =
+    Reads.mapReads[MDNSendCreationId, JsObject] {
+      s => Id.validate(s).fold(e => JsError(e.getMessage), partId => JsSuccess(MDNSendCreationId(partId)))
+    }
+
+  private implicit val mapExtensionFieldRead: Reads[Map[ExtensionFieldName, ExtensionFieldValue]] =
+    Reads.mapReads[ExtensionFieldName, ExtensionFieldValue] { s => JsSuccess(ExtensionFieldName(s)) }
+
+  private implicit val mdnWrites: Writes[MDNSendCreateRequest] = Json.writes[MDNSendCreateRequest]
+  private implicit val mdnObjectWrites: OWrites[MDNSendCreateRequest] = Json.writes[MDNSendCreateRequest]
+  private implicit val mdnRequestReads: Reads[MDNSendCreateRequest] = Json.reads[MDNSendCreateRequest]
+  private implicit val mdnSendRequestReads: Reads[MDNSendRequest] = Json.reads[MDNSendRequest]
+  private implicit val setErrorWrites: Writes[SetError] = Json.writes[SetError]
+  private implicit val mdnNotSentMapWrites: Writes[Map[MDNSendCreationId, SetError]] =
+    mapWrites[MDNSendCreationId, SetError](mdnSendId => mdnSendId.id.value, setErrorWrites)
+  private implicit val mdnResponseWrites: Writes[MDNSendCreateResponse] = Json.writes[MDNSendCreateResponse]
+  private implicit val mdnResponseObjectWrites: OWrites[MDNSendCreateResponse] = Json.writes[MDNSendCreateResponse]
+  private implicit val mdnSentMapWrites: Writes[Map[MDNSendCreationId, MDNSendCreateResponse]] =
+    mapWrites[MDNSendCreationId, MDNSendCreateResponse](mdnSendId => mdnSendId.id.value, mdnResponseWrites)
+  private implicit val mdnSendResponseWrites: Writes[MDNSendResponse] = Json.writes[MDNSendResponse]
+  private implicit val mdnParseResponseWrites: Writes[MDNParseResponse] = Json.writes[MDNParseResponse]
+
+  def deserializeMDNSendRequest(input: JsValue): JsResult[MDNSendRequest] = Json.fromJson[MDNSendRequest](input)
+
+  def deserializeMDNSendCreateRequest(input: JsValue): JsResult[MDNSendCreateRequest] = Json.fromJson[MDNSendCreateRequest](input)
+
+  def serializeMDNSendResponse(mdnSendResponse: MDNSendResponse): JsValue = Json.toJson(mdnSendResponse)
+
+  def serializeMDNResponse(mdnResponse: MDNSendCreateResponse): JsValue = Json.toJson(mdnResponse)
+
+  def deserializeMDNParseRequest(input: JsValue): JsResult[MDNParseRequest] = Json.fromJson[MDNParseRequest](input)
+
+  def serializeMDNParseResponse(mdnParseResponse: MDNParseResponse): JsValue = Json.toJson(mdnParseResponse)
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala
new file mode 100644
index 0000000..e358df6
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala
@@ -0,0 +1,135 @@
+/****************************************************************
+ * 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 org.apache.james.core.MailAddress
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{Properties, SetError}
+import org.apache.james.mailbox.model.MessageId
+import org.apache.james.mdn.MDNReportParser
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{FinalRecipient, ReportingUserAgent, Disposition => JavaDisposition}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+
+import java.util.Locale
+import scala.util.{Failure, Success, Try}
+
+object MDN {
+  val DISPOSITION_NOTIFICATION_TO: String = "Disposition-Notification-To"
+}
+
+case class MDNDispositionInvalidException(description: String) extends Exception
+
+case class ForEmailIdField(originalMessageId: MessageId) extends AnyVal
+
+case class SubjectField(value: String) extends AnyVal
+
+case class TextBodyField(value: String) extends AnyVal
+
+case class ReportUAField(value: String) extends AnyVal {
+  def asJava: Try[ReportingUserAgent] = new MDNReportParser("Reporting-UA: " + value)
+    .reportingUaField
+    .run()
+
+  def validate: Either[MDNSendRequestInvalidException, ReportUAField] =
+    asJava match {
+      case Success(_) => scala.Right(this)
+      case Failure(_) => Left(MDNSendRequestInvalidException(
+        SetError(`type` = SetError.invalidArgumentValue,
+          description = SetErrorDescription("ReportUA can't be parse."),
+          properties = Some(Properties.toProperties(Set("reportingUA"))))))
+    }
+}
+
+case class FinalRecipientField(value: String) extends AnyVal {
+  def asJava: Try[FinalRecipient] = new MDNReportParser("Final-Recipient: " + value)
+    .finalRecipientField
+    .run()
+
+  def getMailAddress: Try[MailAddress] =
+    for {
+      javaFinalRecipient <- asJava
+      mailAddress = new MailAddress(javaFinalRecipient.getFinalRecipient.formatted())
+    } yield mailAddress
+
+  def validate: Either[MDNSendRequestInvalidException, FinalRecipientField] =
+    asJava match {
+      case Success(_) => scala.Right(this)
+      case Failure(_) => Left(MDNSendRequestInvalidException(
+        SetError(`type` = SetError.invalidArgumentValue,
+          description = SetErrorDescription("FinalRecipient can't be parse."),
+          properties = Some(Properties.toProperties(Set("finalRecipient"))))))
+    }
+}
+
+case class OriginalRecipientField(value: String) extends AnyVal
+
+case class OriginalMessageIdField(value: String) extends AnyVal
+
+case class ExtensionFieldName(value: String) extends AnyVal
+
+case class ExtensionFieldValue(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
+
+case class MDNGatewayField(value: String) extends AnyVal
+
+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) {
+  def asJava: Try[JavaDisposition] =
+    Try(JavaDisposition.builder()
+      .`type`(DispositionType.fromString(`type`)
+        .orElseThrow(() => MDNDispositionInvalidException("Disposition \"Type\" is invalid.")))
+      .actionMode(DispositionActionMode.fromString(actionMode)
+        .orElseThrow(() => MDNDispositionInvalidException("Disposition \"ActionMode\" is invalid.")))
+      .sendingMode(DispositionSendingMode.fromString(sendingMode)
+        .orElseThrow(() => MDNDispositionInvalidException("Disposition \"SendingMode\" is invalid.")))
+      .build())
+
+  def validate: Either[MDNSendRequestInvalidException, MDNDisposition] =
+    asJava match {
+      case Success(_) => scala.Right(this)
+      case Failure(exception) => exception match {
+        case exception: MDNDispositionInvalidException => Left(MDNSendRequestInvalidException(
+          SetError(`type` = SetError.invalidArgumentValue,
+            description = SetErrorDescription(exception.description),
+            properties = Some(Properties.toProperties(Set("disposition"))))))
+        case _ => Left(MDNSendRequestInvalidException(
+          SetError(`type` = SetError.invalidArgumentValue,
+            description = SetErrorDescription(exception.getMessage),
+            properties = Some(Properties.toProperties(Set("disposition"))))))
+      }
+    }
+}
\ No newline at end of file
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
index e6260b1..98db1b8 100644
--- 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
@@ -25,10 +25,8 @@ import org.apache.james.jmap.mail.MDNParse._
 import org.apache.james.jmap.method.WithAccountId
 import org.apache.james.mailbox.model.MessageId
 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._
 
@@ -72,39 +70,6 @@ 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(originalMessageId: MessageId) 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, originalMessageId: Option[MessageId]): MDNParsed = {
     val report = mdn.getReport
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
new file mode 100644
index 0000000..1497634
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
@@ -0,0 +1,247 @@
+/****************************************************************
+ * 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 cats.implicits.toTraverseOps
+import org.apache.james.jmap.core.Id.Id
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{AccountId, Id, Properties, SetError}
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+
+import java.util.UUID
+
+object MDNSend {
+  val MDN_ALREADY_SENT_FLAG: String = "$mdnsent"
+}
+
+object MDNId {
+  def generate: MDNId = MDNId(Id.validate(UUID.randomUUID().toString).toOption.get)
+}
+
+case class MDNSendCreationId(id: Id)
+
+case class MDNId(value: Id)
+
+object MDNSendRequestInvalidException {
+  def parse(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]): MDNSendRequestInvalidException = {
+    val setError: SetError = errors.head match {
+      case (path, Seq()) => SetError.invalidArguments(SetErrorDescription(s"'$path' property in MDNSend object is not valid"))
+      case (path, Seq(JsonValidationError(Seq("error.path.missing")))) => SetError.invalidArguments(SetErrorDescription(s"Missing '$path' property in MDNSend object"))
+      case (path, Seq(JsonValidationError(Seq(message)))) => SetError.invalidArguments(SetErrorDescription(s"'$path' property in MDNSend object is not valid: $message"))
+      case (path, _) => SetError.invalidArguments(SetErrorDescription(s"Unknown error on property '$path'"))
+    }
+    MDNSendRequestInvalidException(setError)
+  }
+}
+
+case class MDNSendRequestInvalidException(error: SetError) extends Exception
+
+case class MDNSendNotFoundException(description: String) extends Exception
+
+case class MDNSendForbiddenException() extends Exception
+
+case class MDNSendForbiddenFromException(description: String) extends Exception
+
+case class MDNSendOverQuotaException() extends Exception
+
+case class MDNSendTooLargeException() extends Exception
+
+case class MDNSendRateLimitException() extends Exception
+
+case class MDNSendInvalidPropertiesException() extends Exception
+
+case class MDNSendAlreadySentException() extends Exception
+
+case class IdentityIdNotFoundException(description: String) extends Exception
+
+object MDNSendCreateRequest {
+  private val assignableProperties: Set[String] = Set("forEmailId", "subject", "textBody", "reportingUA",
+    "finalRecipient", "includeOriginalMessage", "disposition", "extensionFields")
+
+  def validateProperties(jsObject: JsObject): Either[MDNSendRequestInvalidException, JsObject] =
+    jsObject.keys.diff(assignableProperties) match {
+      case unknownProperties if unknownProperties.nonEmpty =>
+        Left(MDNSendRequestInvalidException(SetError.invalidArguments(
+          SetErrorDescription("Some unknown properties were specified"),
+          Some(Properties.toProperties(unknownProperties.toSet)))))
+      case _ => scala.Right(jsObject)
+    }
+}
+
+case class MDNSendCreateRequest(forEmailId: ForEmailIdField,
+                                subject: Option[SubjectField],
+                                textBody: Option[TextBodyField],
+                                reportingUA: Option[ReportUAField],
+                                finalRecipient: Option[FinalRecipientField],
+                                includeOriginalMessage: Option[IncludeOriginalMessageField],
+                                disposition: MDNDisposition,
+                                extensionFields: Option[Map[ExtensionFieldName, ExtensionFieldValue]]) {
+  def validate: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    validateDisposition.flatMap(_ => validateReportUA)
+      .flatMap(_ => validateFinalRecipient)
+
+  def validateDisposition: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    disposition.validate
+      .fold(error => Left(error), _ => scala.Right(this))
+
+  def validateReportUA: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    reportingUA match {
+      case None => scala.Right(this)
+      case Some(value) => value.validate.fold(error => Left(error), _ => scala.Right(this))
+    }
+
+  def validateFinalRecipient: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    finalRecipient match {
+      case None => scala.Right(this)
+      case Some(value) => value.validate.fold(error => Left(error), _ => scala.Right(this))
+    }
+}
+
+case class MDNSendCreateResponse(subject: Option[SubjectField],
+                                 textBody: Option[TextBodyField],
+                                 reportingUA: Option[ReportUAField],
+                                 mdnGateway: Option[MDNGatewayField],
+                                 originalRecipient: Option[OriginalRecipientField],
+                                 finalRecipient: Option[FinalRecipientField],
+                                 includeOriginalMessage: Option[IncludeOriginalMessageField],
+                                 originalMessageId: Option[OriginalMessageIdField],
+                                 error: Option[Seq[ErrorField]])
+
+case class MDNSendRequest(accountId: AccountId,
+                          identityId: IdentityId,
+                          send: Map[MDNSendCreationId, JsObject],
+                          onSuccessUpdateEmail: Option[Map[MDNSendCreationId, JsObject]]) extends WithAccountId {
+
+  def validate: Either[IllegalArgumentException, MDNSendRequest] = {
+    val supportedCreationIds: List[MDNSendCreationId] = send.keys.toList
+    onSuccessUpdateEmail.getOrElse(Map())
+      .keys
+      .toList
+      .map(id => validateOnSuccessUpdateEmail(id, supportedCreationIds))
+      .sequence
+      .map(_ => this)
+  }
+
+  private def validateOnSuccessUpdateEmail(creationId: MDNSendCreationId, supportedCreationIds: List[MDNSendCreationId]): Either[IllegalArgumentException, MDNSendCreationId] =
+    if (creationId.id.value.startsWith("#")) {
+      val realId = creationId.id.value.substring(1)
+      val validateId: Either[IllegalArgumentException, MDNSendCreationId] = Id.validate(realId).map(id => MDNSendCreationId(id))
+      validateId.flatMap(mdnSendId => if (supportedCreationIds.contains(mdnSendId)) {
+        scala.Right(mdnSendId)
+      } else {
+        Left(new IllegalArgumentException(s"${creationId.id.value} cannot be referenced in current method call"))
+      })
+    } else {
+      Left(new IllegalArgumentException(s"${creationId.id.value} cannot be retrieved as storage for MDNSend is not yet implemented"))
+    }
+
+  def implicitEmailSetRequest(messageIdResolver: MDNSendCreationId => Either[IllegalArgumentException, Option[MessageId]]): Either[IllegalArgumentException, Option[EmailSetRequest]] =
+    resolveOnSuccessUpdateEmail(messageIdResolver)
+      .map(update =>
+        if (update.isEmpty) {
+          None
+        } else {
+          Some(EmailSetRequest(
+            accountId = accountId,
+            create = None,
+            update = update,
+            destroy = None))
+        })
+
+  def resolveOnSuccessUpdateEmail(messageIdResolver: MDNSendCreationId => Either[IllegalArgumentException, Option[MessageId]]): Either[IllegalArgumentException, Option[Map[UnparsedMessageId, JsObject]]] =
+    onSuccessUpdateEmail.map(map => map.toList
+      .map {
+        case (creationId, json) => messageIdResolver.apply(creationId).map(msgOpt => msgOpt.map(messageId => (EmailSet.asUnparsed(messageId), json)))
+      }
+      .sequence
+      .map(list => list.flatten.toMap))
+      .sequence
+      .map {
+        case Some(value) if value.isEmpty => None
+        case e => e
+      }
+}
+
+case class MDNSendResponse(accountId: AccountId,
+                           sent: Option[Map[MDNSendCreationId, MDNSendCreateResponse]],
+                           notSent: Option[Map[MDNSendCreationId, SetError]])
+
+object MDNSendResults {
+  def empty: MDNSendResults = MDNSendResults(None, None, Map.empty)
+
+  def sent(createSuccess: MDNSendCreateSuccess): MDNSendResults =
+    MDNSendResults(sent = Some(Map(createSuccess.mdnCreationId -> createSuccess.createResponse)),
+      notSent = None,
+      mdnSentIdResolver = Map(createSuccess.mdnCreationId -> createSuccess.forEmailId))
+
+  def notSent(mdnSendId: MDNSendCreationId, throwable: Throwable): MDNSendResults = {
+    val setError: SetError = throwable match {
+      case notFound: MDNSendNotFoundException => SetError.notFound(SetErrorDescription(notFound.description))
+      case _: MDNSendForbiddenException => SetError(SetError.forbiddenValue,
+        SetErrorDescription("Violate an Access Control List (ACL) or other permissions policy."),
+        None)
+      case forbiddenFrom: MDNSendForbiddenFromException => SetError(SetError.forbiddenFromValue,
+        SetErrorDescription(forbiddenFrom.description),
+        None)
+      case _: MDNSendInvalidPropertiesException => SetError(SetError.invalidArgumentValue,
+        SetErrorDescription("The record given is invalid in some way."),
+        None)
+      case _: MDNSendAlreadySentException => SetError.mdnAlreadySent(SetErrorDescription("The message has the $mdnsent keyword already set."))
+      case parseError: MDNSendRequestInvalidException => parseError.error
+    }
+    MDNSendResults(None, Some(Map(mdnSendId -> setError)), Map.empty)
+  }
+
+  def merge(result1: MDNSendResults, result2: MDNSendResults): MDNSendResults = MDNSendResults(
+    sent = (result1.sent ++ result2.sent).reduceOption(_ ++ _),
+    notSent = (result1.notSent ++ result2.notSent).reduceOption(_ ++ _),
+    mdnSentIdResolver = result1.mdnSentIdResolver ++ result2.mdnSentIdResolver)
+}
+
+case class MDNSendCreateSuccess(mdnCreationId: MDNSendCreationId,
+                                createResponse: MDNSendCreateResponse,
+                                forEmailId: MessageId)
+
+case class MDNSendResults(sent: Option[Map[MDNSendCreationId, MDNSendCreateResponse]],
+                          notSent: Option[Map[MDNSendCreationId, SetError]],
+                          mdnSentIdResolver: Map[MDNSendCreationId, MessageId]) {
+
+  def resolveMessageId(sendId: MDNSendCreationId): Either[IllegalArgumentException, Option[MessageId]] =
+    if (sendId.id.value.startsWith("#")) {
+      val realId: String = sendId.id.value.substring(1)
+      val validatedId: Either[IllegalArgumentException, MDNSendCreationId] = Id.validate(realId).map(id => MDNSendCreationId(id))
+      validatedId
+        .left.map(s => new IllegalArgumentException(s))
+        .flatMap(id => retrieveMessageId(id)
+          .map(id => scala.Right(Some(id))).getOrElse(scala.Right(None)))
+    } else {
+      Left(new IllegalArgumentException(s"${sendId.id.value} cannot be retrieved as storage for MDNSend is not yet implemented"))
+    }
+
+  private def retrieveMessageId(creationId: MDNSendCreationId): Option[MessageId] =
+    sent.getOrElse(Map.empty).
+      filter(sentResult => sentResult._1.equals(creationId)).keys
+      .headOption
+      .flatMap(mdnSendId => mdnSentIdResolver.get(mdnSendId))
+
+  def asResponse(accountId: AccountId): MDNSendResponse = MDNSendResponse(accountId, sent, notSent)
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentityGetMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentityGetMethod.scala
index 26c50f7..cabe833 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentityGetMethod.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentityGetMethod.scala
@@ -20,12 +20,13 @@
 package org.apache.james.jmap.method
 
 import eu.timepit.refined.auto._
+
 import javax.inject.Inject
 import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, EMAIL_SUBMISSION, JMAP_CORE}
 import org.apache.james.jmap.core.Invocation.{Arguments, MethodName}
 import org.apache.james.jmap.core._
 import org.apache.james.jmap.json.{IdentitySerializer, ResponseSerializer}
-import org.apache.james.jmap.mail.{Identity, IdentityFactory, IdentityGetRequest, IdentityGetResponse}
+import org.apache.james.jmap.mail.{Identity, IdentityFactory, IdentityGetRequest, IdentityGetResponse, IdentityId}
 import org.apache.james.jmap.routes.SessionSupplier
 import org.apache.james.mailbox.MailboxSession
 import org.apache.james.metrics.api.MetricFactory
@@ -66,3 +67,11 @@ class IdentityGetMethod @Inject() (identityFactory: IdentityFactory,
     SMono.fromCallable(() => identityFactory.listIdentities(mailboxSession))
       .map(request.computeResponse)
 }
+
+case class IdentityResolver @Inject()(identityFactory: IdentityFactory) {
+
+  def resolveIdentityId(identityId: IdentityId, session: MailboxSession): SMono[Option[Identity]] =
+    SMono.fromCallable(() => identityFactory.listIdentities(session)
+      .find(identity => identity.id.equals(identityId)))
+      .subscribeOn(Schedulers.elastic())
+}
\ No newline at end of file
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
index 18f1d4c..bea693b 100644
--- 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
@@ -23,7 +23,7 @@ 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.json.{MDNSerializer, ResponseSerializer}
 import org.apache.james.jmap.mail.{BlobId, BlobUnParsableException, MDNParseRequest, MDNParseResponse, MDNParseResults, MDNParsed}
 import org.apache.james.jmap.routes.{BlobNotFoundException, BlobResolvers, SessionSupplier}
 import org.apache.james.mailbox.model.{MessageId, MultimailboxesSearchQuery, SearchQuery}
@@ -41,7 +41,8 @@ import javax.inject.Inject
 import scala.jdk.OptionConverters._
 import scala.util.Try
 
-class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
+class MDNParseMethod @Inject()(serializer: MDNSerializer,
+                               val blobResolvers: BlobResolvers,
                                val metricFactory: MetricFactory,
                                val mdnEmailIdResolver: MDNEmailIdResolver,
                                val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[MDNParseRequest] {
@@ -56,7 +57,7 @@ class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
       .map(InvocationWithContext(_, invocation.processingContext))
 
   override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, MDNParseRequest] =
-    MDNParseSerializer.deserializeMDNParseRequest(invocation.arguments.value) match {
+    serializer.deserializeMDNParseRequest(invocation.arguments.value) match {
       case JsSuccess(mdnParseRequest, _) => mdnParseRequest.validate
       case errors: JsError => Left(new IllegalArgumentException(Json.stringify(ResponseSerializer.serialize(errors))))
     }
@@ -65,9 +66,9 @@ class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
                                 invocation: Invocation,
                                 mailboxSession: MailboxSession): SMono[Invocation] =
     computeResponse(request, mailboxSession)
-      .map(res => Invocation(
+      .map(response => Invocation(
         methodName,
-        Arguments(MDNParseSerializer.serialize(res).as[JsObject]),
+        Arguments(serializer.serializeMDNParseResponse(response).as[JsObject]),
         invocation.methodCallId))
 
   private def computeResponse(request: MDNParseRequest,
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
new file mode 100644
index 0000000..261b6fb
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
@@ -0,0 +1,312 @@
+/****************************************************************
+ * 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.{MDNSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDN._
+import org.apache.james.jmap.mail.MDNSend.MDN_ALREADY_SENT_FLAG
+import org.apache.james.jmap.mail._
+import org.apache.james.jmap.method.EmailSubmissionSetMethod.LOGGER
+import org.apache.james.jmap.routes.{ProcessingContext, SessionSupplier}
+import org.apache.james.lifecycle.api.Startable
+import org.apache.james.mailbox.model.{FetchGroup, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.fields.{ExtensionField, FinalRecipient, OriginalRecipient, Text}
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.codec.DecodeMonitor
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.mime4j.field.AddressListFieldLenientImpl
+import org.apache.james.mime4j.message.DefaultMessageBuilder
+import org.apache.james.mime4j.stream.MimeConfig
+import org.apache.james.queue.api.MailQueueFactory.SPOOL
+import org.apache.james.queue.api.{MailQueue, MailQueueFactory}
+import org.apache.james.server.core.MailImpl
+import play.api.libs.json.{JsError, JsObject, JsSuccess, Json}
+import reactor.core.scala.publisher.{SFlux, SMono}
+import reactor.core.scheduler.Schedulers
+
+import javax.annotation.PreDestroy
+import javax.inject.Inject
+import javax.mail.internet.MimeMessage
+import scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              val identityResolver: IdentityResolver,
+                              val metricFactory: MetricFactory,
+                              val sessionSupplier: SessionSupplier) extends MethodRequiringAccountId[MDNSendRequest] with Startable {
+  override val methodName: MethodName = MethodName("MDN/send")
+  override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_MDN, JMAP_MAIL, JMAP_CORE)
+  var queue: MailQueue = _
+
+  def init: Unit =
+    queue = mailQueueFactory.createQueue(SPOOL)
+
+  @PreDestroy def dispose: Unit =
+    Try(queue.close())
+      .recover(e => LOGGER.debug("error closing queue", e))
+
+  override def doProcess(capabilities: Set[CapabilityIdentifier],
+                         invocation: InvocationWithContext,
+                         mailboxSession: MailboxSession,
+                         request: MDNSendRequest): SFlux[InvocationWithContext] =
+    identityResolver.resolveIdentityId(request.identityId, mailboxSession)
+      .flatMap(maybeIdentity => maybeIdentity.map(identity => create(identity, request, mailboxSession, invocation.processingContext))
+        .getOrElse(SMono.raiseError(IdentityIdNotFoundException("The IdentityId cannot be found"))))
+      .flatMapMany(createdResults => {
+        val explicitInvocation: InvocationWithContext = InvocationWithContext(
+          invocation = Invocation(
+            methodName = invocation.invocation.methodName,
+            arguments = Arguments(serializer.serializeMDNSendResponse(createdResults._1.asResponse(request.accountId))
+              .as[JsObject]),
+            methodCallId = invocation.invocation.methodCallId),
+          processingContext = createdResults._2)
+
+        val emailSetCall: SMono[InvocationWithContext] = request.implicitEmailSetRequest(createdResults._1.resolveMessageId)
+          .fold(e => SMono.error(e),
+            maybeEmailSetRequest => maybeEmailSetRequest.map(emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+              .getOrElse(SMono.empty))
+
+        SFlux.concat(SMono.just(explicitInvocation), emailSetCall)
+      })
+
+  override def getRequest(mailboxSession: MailboxSession, invocation: Invocation): Either[Exception, MDNSendRequest] =
+    serializer.deserializeMDNSendRequest(invocation.arguments.value) match {
+      case JsSuccess(mdnSendRequest, _) => mdnSendRequest.validate
+      case errors: JsError => Left(new IllegalArgumentException(Json.stringify(ResponseSerializer.serialize(errors))))
+    }
+
+  private def create(identity: Identity,
+                     request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendCreationId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, identity, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            identity: Identity,
+                            mdnSendCreationId: MDNSendCreationId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, identity, mdnSendCreationId, createRequest))
+      .fold(error => (MDNSendResults.notSent(mdnSendCreationId, error) -> processingContext),
+        creation => MDNSendResults.sent(creation) -> processingContext)
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    MDNSendCreateRequest.validateProperties(jsObject)
+      .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+        case JsSuccess(createRequest, _) => createRequest.validate
+        case JsError(errors) => Left(MDNSendRequestInvalidException.parse(errors))
+      })
+
+  private def sendMDN(session: MailboxSession,
+                      identity: Identity,
+                      mdnSendCreationId: MDNSendCreationId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, MDNSendCreateSuccess] =
+    for {
+      mdnRelatedMessageResult <- retrieveRelatedMessageResult(session, requestEntry)
+      mdnRelatedMessageResultAlready <- validateMDNNotAlreadySent(mdnRelatedMessageResult)
+      messageRelated = parseAsMessage(mdnRelatedMessageResultAlready)
+      mailAndResponseAndId <- buildMailAndResponse(identity, session.getUser.asString(), requestEntry, messageRelated)
+      _ <- Try(queue.enQueue(mailAndResponseAndId._1)).toEither
+    } yield {
+      MDNSendCreateSuccess(
+        mdnCreationId = mdnSendCreationId,
+        createResponse = mailAndResponseAndId._2,
+        forEmailId = mdnRelatedMessageResultAlready.getMessageId)
+    }
+
+  private def retrieveRelatedMessageResult(session: MailboxSession, requestEntry: MDNSendCreateRequest): Either[MDNSendNotFoundException, MessageResult] =
+    messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+
+  private def validateMDNNotAlreadySent(relatedMessageResult: MessageResult): Either[MDNSendAlreadySentException, MessageResult] =
+    if (relatedMessageResult.getFlags.contains(MDN_ALREADY_SENT_FLAG)) {
+      Left(MDNSendAlreadySentException())
+    } else {
+      scala.Right(relatedMessageResult)
+    }
+
+  private def buildMailAndResponse(identity: Identity, sender: String, requestEntry: MDNSendCreateRequest, originalMessage: Message): Either[Throwable, (MailImpl, MDNSendCreateResponse)] =
+    for {
+      mailRecipient <- getMailRecipient(originalMessage)
+      mdnFinalRecipient <- getMDNFinalRecipient(requestEntry, identity)
+      mdnOriginalRecipient = OriginalRecipient.builder().originalRecipient(Text.fromRawText(sender)).build()
+      mdn = buildMDN(requestEntry, originalMessage, mdnFinalRecipient, mdnOriginalRecipient)
+      subject = buildMessageSubject(requestEntry, originalMessage)
+      (mailImpl, mimeMessage) = buildMailAndMimeMessage(sender, mailRecipient, subject, mdn)
+    } yield {
+      (mailImpl, buildMDNSendCreateResponse(requestEntry, mdn, mimeMessage))
+    }
+
+  private def buildMailAndMimeMessage(sender: String, recipient: String, subject: String, mdn: MDN): (MailImpl, MimeMessage) = {
+    val mimeMessage: MimeMessage = mdn.asMimeMessage()
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, recipient)
+    mimeMessage.setSubject(subject)
+    mimeMessage.saveChanges()
+
+    val mailImpl: MailImpl = MailImpl.builder()
+      .name(MDNId.generate.value)
+      .sender(sender)
+      .addRecipient(recipient)
+      .mimeMessage(mimeMessage)
+      .build()
+    mailImpl -> mimeMessage
+  }
+
+  private def getMailRecipient(originalMessage: Message): Either[MDNSendNotFoundException, String] =
+    originalMessage.getHeader.getFields(DISPOSITION_NOTIFICATION_TO)
+      .asScala
+      .headOption
+      .map(field => AddressListFieldLenientImpl.PARSER.parse(field, new DecodeMonitor))
+      .map(addressListField => addressListField.getAddressList)
+      .map(addressList => addressList.flatten())
+      .flatMap(mailboxList => mailboxList.stream().findAny().toScala)
+      .map(mailbox => mailbox.getAddress)
+      .toRight(MDNSendNotFoundException("Invalid \"Disposition-Notification-To\" header field."))
+
+  private def getMDNFinalRecipient(requestEntry: MDNSendCreateRequest, identity: Identity): Either[Throwable, FinalRecipient] =
+    requestEntry.finalRecipient
+      .map(finalRecipient => finalRecipient.getMailAddress.toEither)
+      .map {
+        case scala.Right(mailAddress) if mailAddress.equals(identity.email) => scala.Right(requestEntry.finalRecipient.get.asJava.get)
+        case scala.Right(_) => Left(MDNSendForbiddenFromException("The user is not allowed to use the given \"finalRecipient\" property"))
+        case Left(error) => Left(error)
+      }
+      .getOrElse(scala.Right(FinalRecipient.builder()
+        .finalRecipient(Text.fromRawText(identity.email.asString()))
+        .build()))
+
+  private def buildMDN(requestEntry: MDNSendCreateRequest, originalMessage: Message, finalRecipient: FinalRecipient, originalRecipient: OriginalRecipient): MDN = {
+    val reportBuilder: MDNReport.Builder = MDNReport.builder()
+      .dispositionField(requestEntry.disposition.asJava.get)
+      .finalRecipientField(finalRecipient)
+      .originalRecipientField(originalRecipient)
+
+    originalMessage.getHeader.getFields("Message-ID")
+      .asScala
+      .map(field => reportBuilder.originalMessageIdField(field.getBody))
+
+    requestEntry.reportingUA
+      .map(uaField => uaField.asJava
+        .map(reportingUserAgent => reportBuilder.reportingUserAgentField(reportingUserAgent)))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(
+        ExtensionField.builder()
+          .fieldName(extension._1.value)
+          .rawValue(extension._2.value)
+          .build())))
+
+    originalMessage.getHeader.getFields(EmailHeaderName.MESSAGE_ID.value)
+      .asScala
+      .headOption
+      .map(messageIdHeader => reportBuilder.originalMessageIdField(TextHeaderValue.from(messageIdHeader).value))
+
+    MDN.builder()
+      .report(reportBuilder.build())
+      .humanReadableText(buildMDNHumanReadableText(requestEntry))
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => originalMessage)
+        .toJava)
+      .build()
+  }
+
+  private def buildMDNHumanReadableText(requestEntry: MDNSendCreateRequest): String =
+    requestEntry.textBody.map(textBody => textBody.value)
+      .getOrElse(s"The email has been ${requestEntry.disposition.`type`} on your recipient's computer")
+
+  private def buildMessageSubject(requestEntry: MDNSendCreateRequest, originalMessage: Message): String =
+    requestEntry.subject
+      .map(subject => subject.value)
+      .getOrElse(s"""[Received] ${originalMessage.getSubject}""")
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN, mimeMessage: MimeMessage): MDNSendCreateResponse =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mimeMessage.getSubject))
+      },
+      textBody = requestEntry.textBody match {
+        case Some(_) => None
+        case None => Some(TextBodyField(mdn.getHumanReadableText))
+      },
+      reportingUA = requestEntry.reportingUA match {
+        case Some(_) => None
+        case None => mdn.getReport.getReportingUserAgentField
+          .map(ua => ReportUAField(ua.fieldValue()))
+          .toScala
+      },
+      mdnGateway = mdn.getReport.getGatewayField
+        .map(gateway => MDNGatewayField(gateway.fieldValue()))
+        .toScala,
+      originalRecipient = mdn.getReport.getOriginalRecipientField
+        .map(originalRecipient => OriginalRecipientField(originalRecipient.fieldValue()))
+        .toScala,
+      includeOriginalMessage = requestEntry.includeOriginalMessage match {
+        case Some(_) => None
+        case None => Some(IncludeOriginalMessageField(mdn.getOriginalMessage.isPresent))
+      },
+      error = Option(mdn.getReport.getErrorFields.asScala
+        .map(error => ErrorField(error.getText.formatted()))
+        .toSeq)
+        .filter(error => error.nonEmpty),
+      finalRecipient = requestEntry.finalRecipient match {
+        case Some(_) => None
+        case None => Some(FinalRecipientField(mdn.getReport.getFinalRecipientField.fieldValue()))
+      },
+      originalMessageId = mdn.getReport.getOriginalMessageIdField
+        .map(originalMessageId => OriginalMessageIdField(originalMessageId.getOriginalMessageId))
+        .toScala)
+
+  private def parseAsMessage(messageRelated: MessageResult): Message = {
+    val messageBuilder: DefaultMessageBuilder = new DefaultMessageBuilder
+    messageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE)
+    messageBuilder.setDecodeMonitor(DecodeMonitor.SILENT)
+    messageBuilder.parseMessage(messageRelated.getFullContent.getInputStream)
+  }
+}
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 ab9b2fc..4e149d5 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.{RequestTooLargeException, UnsupportedFilterException, UnsupportedNestingException, UnsupportedRequestParameterException, UnsupportedSortException}
+import org.apache.james.jmap.mail.{IdentityIdNotFoundException, 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
@@ -84,6 +84,7 @@ trait MethodRequiringAccountId[REQUEST <: WithAccountId] extends Method {
         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: IdentityIdNotFoundException => SFlux.just[InvocationWithContext] (InvocationWithContext(Invocation.error(ErrorCode.InvalidArguments, e.description, invocation.invocation.methodCallId), invocation.processingContext))
         case e: Throwable => SFlux.raiseError[InvocationWithContext] (e)
       }
 
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/MDNSerializationTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/MDNSerializationTest.scala
new file mode 100644
index 0000000..cabfa43
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/MDNSerializationTest.scala
@@ -0,0 +1,275 @@
+/****************************************************************
+ * 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.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core._
+import org.apache.james.jmap.json.Fixture.id
+import org.apache.james.jmap.json.MDNSerializationTest.{ACCOUNT_ID, FACTORY, SERIALIZER}
+import org.apache.james.jmap.mail._
+import org.apache.james.mailbox.model.{MessageId, TestMessageId}
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+import play.api.libs.json.{JsResult, JsValue, Json}
+
+object MDNSerializationTest {
+  private val FACTORY: MessageId.Factory = new TestMessageId.Factory
+
+  private val SERIALIZER: MDNSerializer = new MDNSerializer(FACTORY)
+
+  private val ACCOUNT_ID: AccountId = AccountId(id)
+}
+
+class MDNSerializationTest extends AnyWordSpec with Matchers {
+
+  "Deserialize MDNSendRequest" should {
+    "Request should be success" in {
+      val mdnSendRequestActual: JsResult[MDNSendRequest] = SERIALIZER.deserializeMDNSendRequest(
+        Json.parse("""{
+                     |  "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8",
+                     |  "identityId": "I64588216",
+                     |  "send": {
+                     |    "k1546": {
+                     |      "forEmailId": "1",
+                     |      "subject": "Read receipt for: World domination",
+                     |      "textBody": "This receipt",
+                     |      "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+                     |      "finalRecipient": "rfc822; tungexplorer@linagora.com",
+                     |      "includeOriginalMessage": true,
+                     |      "disposition": {
+                     |        "actionMode": "manual-action",
+                     |        "sendingMode": "mdn-sent-manually",
+                     |        "type": "displayed"
+                     |      },
+                     |      "extensionFields": {
+                     |        "EXTENSION-EXAMPLE": "example.com"
+                     |      }
+                     |    }
+                     |  },
+                     |  "onSuccessUpdateEmail": {
+                     |    "#k1546": {
+                     |      "keywords/$$mdnsent": true
+                     |    }
+                     |  }
+                     |}""".stripMargin))
+
+      assert(mdnSendRequestActual.isSuccess)
+    }
+
+    "Request should be success with several MDN object" in {
+      val mdnSendRequestActual: JsResult[MDNSendRequest] = SERIALIZER.deserializeMDNSendRequest(
+        Json.parse("""{
+                     |    "accountId": "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8",
+                     |    "identityId": "I64588216",
+                     |    "send": {
+                     |        "k1546": {
+                     |            "forEmailId": "1",
+                     |            "subject": "Read receipt for: World domination",
+                     |            "textBody": "This receipt",
+                     |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+                     |            "finalRecipient": "rfc822; tungexplorer@linagora.com",
+                     |            "includeOriginalMessage": true,
+                     |            "disposition": {
+                     |                "actionMode": "manual-action",
+                     |                "sendingMode": "mdn-sent-manually",
+                     |                "type": "displayed"
+                     |            },
+                     |            "extensionFields": {
+                     |                "EXTENSION-EXAMPLE": "example.com"
+                     |            }
+                     |        },
+                     |        "k1547": {
+                     |            "forEmailId": "1",
+                     |            "subject": "Read receipt for: World domination",
+                     |            "textBody": "This receipt",
+                     |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+                     |            "finalRecipient": "rfc822; tungexplorer@linagora.com",
+                     |            "includeOriginalMessage": true,
+                     |            "disposition": {
+                     |                "actionMode": "manual-action",
+                     |                "sendingMode": "mdn-sent-manually",
+                     |                "type": "displayed"
+                     |            },
+                     |            "extensionFields": {
+                     |                "EXTENSION-EXAMPLE": "example.com"
+                     |            }
+                     |        },
+                     |        "k1548": {
+                     |            "forEmailId": "1",
+                     |            "subject": "Read receipt for: World domination",
+                     |            "textBody": "This receipt",
+                     |            "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+                     |            "finalRecipient": "rfc822; tungexplorer@linagora.com",
+                     |            "includeOriginalMessage": true,
+                     |            "disposition": {
+                     |                "actionMode": "manual-action",
+                     |                "sendingMode": "mdn-sent-manually",
+                     |                "type": "displayed"
+                     |            },
+                     |            "extensionFields": {
+                     |                "EXTENSION-EXAMPLE": "example.com"
+                     |            }
+                     |        }
+                     |
+                     |    }
+                     |}""".stripMargin))
+
+      assert(mdnSendRequestActual.isSuccess)
+    }
+
+    "EntryRequest should be success" in {
+      val entryRequestActual: JsResult[MDNSendCreateRequest] = SERIALIZER.deserializeMDNSendCreateRequest(
+        Json.parse("""{
+                     |    "forEmailId": "1",
+                     |    "subject": "Read receipt for: World domination",
+                     |    "textBody": "This receipt",
+                     |    "reportingUA": "joes-pc.cs.example.com; Foomail 97.1",
+                     |    "finalRecipient": "rfc822; tungexplorer@linagora.com",
+                     |    "includeOriginalMessage": true,
+                     |    "disposition": {
+                     |        "actionMode": "manual-action",
+                     |        "sendingMode": "mdn-sent-manually",
+                     |        "type": "displayed"
+                     |    },
+                     |    "extensionFields": {
+                     |        "EXTENSION-EXAMPLE": "example.com"
+                     |    }
+                     |}""".stripMargin))
+
+      assert(entryRequestActual.isSuccess)
+    }
+  }
+
+  "Serialize MDNSendResponse" should {
+    "MDNSendResponse should be success" in {
+      val mdn: MDNSendCreateResponse = MDNSendCreateResponse(
+        subject = Some(SubjectField("Read receipt for: World domination")),
+        textBody = Some(TextBodyField("This receipt")),
+        reportingUA = Some(ReportUAField("joes-pc.cs.example.com; Foomail 97.1")),
+        finalRecipient = Some(FinalRecipientField("rfc822; tungexplorer@linagora.com")),
+        originalRecipient = Some(OriginalRecipientField("rfc822; tungexplorer@linagora.com")),
+        mdnGateway = Some(MDNGatewayField("mdn gateway 1")),
+        error = None,
+        includeOriginalMessage = Some(IncludeOriginalMessageField(false)),
+        originalMessageId = Some(OriginalMessageIdField("<19...@example.org>")))
+
+      val idSent: MDNSendCreationId = MDNSendCreationId(Id.validate("k1546").toOption.get)
+      val idNotSent: MDNSendCreationId = MDNSendCreationId(Id.validate("k01").toOption.get)
+
+      val response: MDNSendResponse = MDNSendResponse(
+        accountId = ACCOUNT_ID,
+        sent = Some(Map(idSent -> mdn)),
+        notSent = Some(Map(idNotSent -> SetError(SetError.mdnAlreadySentValue,
+          SetErrorDescription("mdnAlreadySent description"),
+          None))))
+
+      val actualValue: JsValue = SERIALIZER.serializeMDNSendResponse(response)
+
+      val expectedValue: JsValue = Json.parse(
+        """{
+          |  "accountId" : "aHR0cHM6Ly93d3cuYmFzZTY0ZW5jb2RlLm9yZy8",
+          |  "sent" : {
+          |    "k1546" : {
+          |      "subject" : "Read receipt for: World domination",
+          |      "textBody" : "This receipt",
+          |      "reportingUA" : "joes-pc.cs.example.com; Foomail 97.1",
+          |      "mdnGateway" : "mdn gateway 1",
+          |      "originalRecipient" : "rfc822; tungexplorer@linagora.com",
+          |      "finalRecipient" : "rfc822; tungexplorer@linagora.com",
+          |      "includeOriginalMessage" : false,
+          |      "originalMessageId": "<19...@example.org>"
+          |    }
+          |  },
+          |  "notSent" : {
+          |    "k01" : {
+          |      "type" : "mdnAlreadySent",
+          |      "description" : "mdnAlreadySent description"
+          |    }
+          |  }
+          |}""".stripMargin)
+      actualValue should equal(expectedValue)
+    }
+  }
+
+  "Serialize MDNParseResponse" should {
+    "MDNParseResponse should success" in {
+      val mdnParse: MDNParsed = MDNParsed(
+        forEmailId = Some(ForEmailIdField(FACTORY.fromString("1"))),
+        subject = Some(SubjectField("Read: test")),
+        textBody = Some(TextBodyField("To: magiclan@linagora.com\\r\\nSubject: test\\r\\nMessage was displayed on Tue Mar 30 2021 10:31:50 GMT+0700 (Indochina Time)")),
+        reportingUA = Some(ReportUAField("OpenPaaS Unified Inbox; UA_Product")),
+        finalRecipient = FinalRecipientField("rfc822; tungexplorer@linagora.com"),
+        originalMessageId = Some(OriginalMessageIdField("<63...@linagora.com>")),
+        originalRecipient = Some(OriginalRecipientField("rfc822; tungexplorer@linagora.com")),
+        includeOriginalMessage = IncludeOriginalMessageField(true),
+        disposition = MDNDisposition(
+          actionMode = "manual-action",
+          sendingMode = "mdn-sent-manually",
+          `type` = "displayed"),
+        error = Some(Seq(ErrorField("Message1"), ErrorField("Message2"))),
+        extensionFields = Some(Map("X-OPENPAAS-IP" -> " 177.177.177.77", "X-OPENPAAS-PORT" -> " 8000")))
+
+      val mdnParseResponse: MDNParseResponse = MDNParseResponse(
+        accountId = AccountId(Id.validate("29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6").toOption.get),
+        parsed = Some(Map(BlobId(Id.validate("1").toOption.get) -> mdnParse)),
+        notFound = Some(MDNNotFound(Set(Id.validate("123").toOption.get))),
+        notParsable = Some(MDNNotParsable(Set(Id.validate("456").toOption.get))))
+
+      val actualValue: JsValue = SERIALIZER.serializeMDNParseResponse(mdnParseResponse)
+
+      val expectedValue: JsValue = Json.parse(
+        """{
+          |    "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+          |    "parsed": {
+          |        "1": {
+          |            "forEmailId": "1",
+          |            "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"
+          |            }
+          |        }
+          |    },
+          |    "notFound": [
+          |        "123"
+          |    ],
+          |    "notParsable": [
+          |        "456"
+          |    ]
+          |}""".stripMargin)
+      actualValue should equal(expectedValue)
+    }
+  }
+}

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org