You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by GitBox <gi...@apache.org> on 2021/04/12 08:45:50 UTC

[GitHub] [james-project] vttranlina opened a new pull request #385: [WIP] JAMES-3520 MDN/send

vttranlina opened a new pull request #385:
URL: https://github.com/apache/james-project/pull/385


   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r616573832



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,316 @@
+/****************************************************************
+ * 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.core.MailAddress
+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, 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 identifyResolver: IdentifyResolver,
+                              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] = {
+    identifyResolver.resolveIdentityId(request.identityId, mailboxSession)
+      .flatMap(maybeIdentity => if (maybeIdentity.isEmpty) {
+        SMono.raiseError(IdentityIdNotFoundException("The IdentityId cannot be found"))
+      } else {
+        create(maybeIdentity.get, request, mailboxSession, invocation.processingContext)
+      })
+      .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 = getOriginalMessage(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[Exception, (MailImpl, MDNSendCreateResponse)] =
+    for {
+      mailRecipient <- getMailRecipient(originalMessage)
+      mdnFinalRecipient <- getMDNFinalRecipient(requestEntry, identity)
+      mdn = buildMDN(requestEntry, originalMessage, mdnFinalRecipient)
+      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[MDNSendForbiddenFromException, FinalRecipient] =
+    requestEntry.finalRecipient
+      .map(finalRecipient => finalRecipient.getMailAddress)
+      .map(mayBeMailAddress => (mayBeMailAddress.isSuccess && mayBeMailAddress.get.equals(identity.email)))
+      .map {
+        case true => scala.Right(requestEntry.finalRecipient.get.asJava.get)
+        case false => Left(MDNSendForbiddenFromException("The user is not allowed to use the given \"finalRecipient\" property"))
+      }
+      .getOrElse(scala.Right(FinalRecipient.builder()
+        .finalRecipient(Text.fromRawText(identity.email.asString()))
+        .build()))
+
+  private def buildMDN(requestEntry: MDNSendCreateRequest, originalMessage: Message, finalRecipient: FinalRecipient): MDN = {
+    val reportBuilder: MDNReport.Builder = MDNReport.builder()
+      .dispositionField(requestEntry.disposition.asJava.get)
+      .finalRecipientField(finalRecipient)
+      .originalRecipientField(originalMessage.getTo.asScala.head.toString)

Review comment:
       `.originalRecipientField(mailboxSession.getUser().asString())`
   
   From the draft we use the current user. We should be doing the same here IMO.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r616374148



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -212,21 +212,17 @@ class MDNSendMethod @Inject()(serializer: MDNSerializer,
       .map(mailbox => mailbox.getAddress)
       .toRight(MDNSendNotFoundException("Invalid \"Disposition-Notification-To\" header field."))
 
-  private def getMDNFinalRecipient(requestEntry: MDNSendCreateRequest, identity: Identity): Either[MDNSendForbiddenFromException, FinalRecipient] = {
-    if (requestEntry.finalRecipient.isEmpty) {
-      scala.Right(FinalRecipient.builder()
-        .finalRecipient(Text.fromRawText(identity.email.asString()))
-        .build())
-    }
-    else {
-      val tryMailAddress: Try[MailAddress] = requestEntry.finalRecipient.get.getMailAddress
-      if (tryMailAddress.isSuccess && tryMailAddress.get.equals(identity.email)) {
-        scala.Right(requestEntry.finalRecipient.get.asJava.get)
-      } else {
-        Left(MDNSendForbiddenFromException("The user is not allowed to use the given \"finalRecipient\" property"))
+  private def getMDNFinalRecipient(requestEntry: MDNSendCreateRequest, identity: Identity): Either[MDNSendForbiddenFromException, FinalRecipient] =
+    requestEntry.finalRecipient
+      .map(finalRecipient => finalRecipient.getMailAddress)
+      .map(mayBeMailAddress => (mayBeMailAddress.isSuccess && mayBeMailAddress.get.equals(identity.email)))

Review comment:
       We should be able to do some kind more advanced map here;
   
   ```
   .map {
      case scala.Right(finalRecipient) if finalRecipient.equals(identity.email) => scala.Right(finalRecipient.asJava.get)
      case scala.Right(finalRecipient) => Left(MDNSendForbiddenFromException("The user is not allowed to use the given \"finalRecipient\" property"))
      case Left(e) => Left(e)
   }
       .getOrElse(scala.Right(FinalRecipient.builder()
           .finalRecipient(Text.fromRawText(identity.email.asString()))
           .build()))
   ```
   
   Yes scala is super expressive!

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -59,7 +59,7 @@ class MDNSendMethod @Inject()(serializer: MDNSerializer,
                               mailQueueFactory: MailQueueFactory[_ <: MailQueue],
                               messageIdManager: MessageIdManager,
                               emailSetMethod: EmailSetMethod,
-                              val identifyStore: IdentifyStore,
+                              val identifyResolver: IdentifyResolver,

Review comment:
       identifyResolver -> identityResolver




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] Arsnael commented on pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
Arsnael commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-818403957


   Did you run your tests locally? Bunch of errors, that seem easy to fix. For example:
   ```
   org.opentest4j.AssertionFailedError: 
   
   expected: "Reporting-UA: UA_name; UA_product
   MDN-Gateway: postal;5 rue Charles mercier
   Original-Recipient: rfc822; originalRecipient
   Final-Recipient: rfc822; final_recipient
   Original-Message-ID: original_message_id
   Disposition: automatic-action/MDN-sent-automatically;processed/error,failed
   "
   but was : "Reporting-UA: UA_name; UA_product
   MDN-Gateway: postal; 5 rue Charles mercier
   Original-Recipient: rfc822; originalRecipient
   Final-Recipient: rfc822; final_recipient
   Original-Message-ID: original_message_id
   Disposition: automatic-action/MDN-sent-automatically;processed/error,failed
   "
   	at org.apache.james.mdn.MDNReportFormattingTest.generateMDNReportShouldFormatGatewayWithExoticNameType(MDNReportFormattingTest.java:408)
   ```
   
   It seems you miss a space in `MDN-Gateway: postal;5 rue Charles mercier`, after `portal;`... You have similar issues in other mdn tests


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] Arsnael commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
Arsnael commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r618920244



##########
File path: 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,1796 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val mdnSendRequest: 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": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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; ${BOB.asString}",
+           |                        "includeOriginalMessage": false,
+           |                        "originalRecipient": "rfc822; ${BOB.asString()}"
+           |                    }
+           |                }
+           |            },
+           |            "c1"
+           |        ],
+           |        [
+           |            "Email/set",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "oldState": "23",
+           |                "newState": "42",
+           |                "updated": {
+           |                    "${emailIdRelated.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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)

Review comment:
       You even added the header in the requestSpecification in the setup... That's even better, thanks :)




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r611534839



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
##########
@@ -0,0 +1,216 @@
+/****************************************************************
+ * 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 eu.timepit.refined.refineV
+import org.apache.james.jmap.core.Id.{Id, IdConstraint}
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{AccountId, SetError}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+
+object MDNSend {
+  type MDNSendId = Id
+}
+
+object MDNSendParseException {

Review comment:
       MDNSendException ?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })

Review comment:
       ```
       val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
         if (isMDNSentAlready(messageResult)) {
           Left(MDNSendAlreadySentException())
         } else {
           scala.Right(messageResult)
         }
       })
   ```
   
   can we extract this to a sub-method?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)

Review comment:
       Wrong. Before in JMAP spec the emailId was called messageId, we kept this wording in James code base but this is confusing as MIME message specification is different. Mime message id is a header and is different from the emailId.
   
   Here we can likely omit the Message-Id field. Calling mimeMessage.saveChanges should be enough to position it...

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().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

Review comment:
       Why would we have extension field if the client did not specify it?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)

Review comment:
       We could reuse the subject of the related message and prefix it with `[Received] xxxx`IMO. What is the spec saying?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
##########
@@ -0,0 +1,216 @@
+/****************************************************************
+ * 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 eu.timepit.refined.refineV
+import org.apache.james.jmap.core.Id.{Id, IdConstraint}
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{AccountId, SetError}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+
+object MDNSend {
+  type MDNSendId = Id
+}
+
+object MDNSendParseException {
+  def parse(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]) : MDNSendParseException = {
+    val 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'"))
+    }
+    MDNSendParseException(setError)
+  }
+}
+case class MDNSendParseException(error: SetError) extends Exception
+case class MDNSendForEmailIdNotFoundException(description: String) extends Exception
+case class MDNSendForbiddenException() extends Exception
+case class MDNSendForbiddenFromException() 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 MDNGatewayField(value: String) extends AnyVal
+
+object MDNSendCreateRequest {
+  private val assignableProperties: Set[String] = Set("forEmailId", "subject", "textBody", "reportingUA",
+    "finalRecipient", "includeOriginalMessage", "disposition", "extensionFields")
+
+  def validateProperties(jsObject: JsObject): Either[MDNSendParseException, JsObject] =
+    jsObject.keys.diff(assignableProperties) match {
+      case unknownProperties if unknownProperties.nonEmpty =>
+        Left(MDNSendParseException(SetError.invalidArguments(
+          SetErrorDescription("Some unknown properties were specified"),
+          Some(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[String, String]]) {
+  def validate : Either[MDNSendParseException, MDNSendCreateRequest] =
+    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],
+                                 error: Option[Seq[ErrorField]],
+                                 extensionFields: Option[Map[String, String]])
+
+case class IdentityId(id: Id)
+
+case class MDNSendRequest(accountId: AccountId,
+                          identityId: IdentityId,
+                          send: Map[MDNSendId, JsObject],
+                          onSuccessUpdateEmail: Option[Map[MDNSendId, JsObject]]) extends WithAccountId {
+
+  def validate: Either[IllegalArgumentException, MDNSendRequest] = {
+    val supportedCreationIds: List[MDNSendId] = send.keys.toList
+    onSuccessUpdateEmail.getOrElse(Map())
+      .keys
+      .toList
+      .map(id => validateOnSuccessUpdateEmail(id, supportedCreationIds))
+      .sequence
+      .map(_ => this)
+  }
+
+  private def validateOnSuccessUpdateEmail(creationId: MDNSendId, supportedCreationIds: List[MDNSendId]): Either[IllegalArgumentException, MDNSendId] =
+    if (creationId.value.startsWith("#")) {
+      val realId = creationId.value.substring(1)
+      val validatedId: Either[String, MDNSendId] = refineV[IdConstraint](realId)
+      validatedId
+        .left.map(s => new IllegalArgumentException(s))
+        .flatMap(id => if (supportedCreationIds.contains(id)) {
+          scala.Right(id)
+        } else {
+          Left(new IllegalArgumentException(s"$creationId cannot be referenced in current method call"))
+        })
+    } else {
+      Left(new IllegalArgumentException(s"$creationId cannot be retrieved as storage for MDNSend is not yet implemented"))
+    }
+
+  def implicitEmailSetRequest(messageIdResolver: MDNSendId => Either[IllegalArgumentException, Option[MessageId]]): Either[IllegalArgumentException, EmailSetRequest] =
+    resolveOnSuccessUpdateEmail(messageIdResolver)
+      .map(update => EmailSetRequest(
+        accountId = accountId,
+        create = None,
+        update = update,
+        destroy = None))
+
+  def resolveOnSuccessUpdateEmail(messageIdResolver: MDNSendId => 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
+}
+
+case class MDNSendResponse(accountId: AccountId,
+                           sent: Option[Map[MDNSendId, MDNSendCreateResponse]],
+                           notSent: Option[Map[MDNSendId, SetError]])
+
+object MDNSendResults {
+  def empty: MDNSendResults = MDNSendResults(None, None, None)
+  def sent(mdnSendId: MDNSendId, mdnResponse: MDNSendCreateResponse, forEmailId: MessageId): MDNSendResults =
+    MDNSendResults(Some(Map(mdnSendId -> mdnResponse)), None, Some(Map(mdnSendId -> forEmailId)))
+
+  def notSent(mdnSendId: MDNSendId, throwable: Throwable): MDNSendResults = {
+    val setError: SetError = throwable match {
+      case notFound: MDNSendForEmailIdNotFoundException => SetError.notFound(SetErrorDescription(notFound.description))
+      case _: MDNSendForbiddenException => SetError(SetError.forbiddenValue,
+        SetErrorDescription("Violate an Access Control List (ACL) or other permissions policy."),
+        None)
+      case _: MDNSendForbiddenFromException => SetError(SetError.forbiddenFromValue,
+        SetErrorDescription("The user is not allowed to use the given \"finalRecipient\" property."),
+        None)
+      case _: MDNSendOverQuotaException => SetError(SetError.overQuotaValue,
+        SetErrorDescription("Exceed a server-defined limit on the number or total size of sent MDNs."),
+        None)
+      case _: MDNSendTooLargeException => SetError(SetError.tooLargeValue,
+        SetErrorDescription("Limit for the maximum size of an MDN or more generally, on email message."),
+        None)
+      case _: MDNSendRateLimitException => SetError(SetError.rateLimitValue,
+        SetErrorDescription("Too many MDNs or email messages have been created recently, and a server-defined rate limit has been reached. It may work if tried again later."),
+        None)
+      case _: MDNSendInvalidPropertiesException => SetError(SetError.invalidArgumentValue,
+        SetErrorDescription("The record given is invalid in some way."),
+        None)
+      case _: MDNSendAlreadySentException => SetError(SetError.mdnAlreadySentValue,
+        SetErrorDescription("The message has the $mdnsent keyword already set."),
+        None)
+      case parseError: MDNSendParseException => parseError.error
+    }
+    MDNSendResults(None, Some(Map(mdnSendId -> setError)), None)
+  }
+
+  def merge(result1: MDNSendResults, result2: MDNSendResults): MDNSendResults = MDNSendResults(
+    sent = (result1.sent ++ result2.sent).reduceOption((sent1, sent2) => sent1 ++ sent2),
+    notSent = (result1.notSent ++ result2.notSent).reduceOption((notSent1, notSent2) => notSent1 ++ notSent2),
+    mdnSentIdMapper = (result1.mdnSentIdMapper ++ result2.mdnSentIdMapper).reduceOption((mapper1, mapper2) => mapper1 ++ mapper2),
+  )
+}
+
+
+case class MDNSendResults(sent: Option[Map[MDNSendId, MDNSendCreateResponse]],
+                          notSent: Option[Map[MDNSendId, SetError]],
+                          mdnSentIdMapper: Option[Map[MDNSendId, MessageId]]) {

Review comment:
       ```suggestion
                             mdnSentIdMapper: Map[MDNSendId, MessageId]) {
   ```
   
   Would IMO be simpler...
   
   Also `mdnSentIdResolver` would be a better name?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)

Review comment:
       ```
           queue.enQueue(mail)
   ```
   
   This could fail. Let's wrap it in a try/either...

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/package.scala
##########
@@ -0,0 +1,15 @@
+package org.apache.james.jmap
+
+import eu.timepit.refined.collection.NonEmpty
+import eu.timepit.refined.refineV
+import eu.timepit.refined.types.string.NonEmptyString
+import org.apache.james.jmap.core.Properties
+
+package object mail {
+
+  def toProperties(strings: Set[String]): Properties = Properties(strings

Review comment:
       IMO this method should be moved to the Properties object

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())

Review comment:
       orElseThrow what?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })

Review comment:
       We would greatly benefit from the `for ... yield ...` paradigm:
   
   ```
   for {
      messageResult <- validateMDNNotSentYet(...)
      (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
      enqueue <- Try(queue.enQueue(mail))
   } yield {
      MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId
   }
   ```

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))

Review comment:
       ```suggestion
       requestEntry.reportingUA.map(reportBuilder.reportingUserAgentField(_.asJava))
   ```
   
   See above

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")

Review comment:
       relatedMessageResult

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))

Review comment:
       Can't we import `javax.mail.Message.RecipientType.TO` rather than using the FQDN?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())

Review comment:
       IMO our scala types should be smart enough to have a `asJava` method so that here we could just write:
   
   ```
   MDNReport.builder()
     .disposition(requestEntry.disposition.asJava)
     .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).asJava)
     // etc...
   ```
   
   

##########
File path: server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/MDNSendSerializationTest.scala
##########
@@ -0,0 +1,152 @@
+/****************************************************************
+ * 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.MDNSendSerializationTest.{ACCOUNT_ID, FACTORY, SERIALIZER}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.mail.{FinalRecipientField, ForEmailIdField, IdentityId, IncludeOriginalMessageField, MDNDisposition, MDNGatewayField, MDNSendCreateRequest, MDNSendCreateResponse, MDNSendRequest, MDNSendResponse, OriginalMessageIdField, OriginalRecipientField, ReportUAField, SubjectField, TextBodyField}
+import org.apache.james.mailbox.model.{MessageId, TestMessageId}
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+import play.api.libs.json.{JsSuccess, Json}
+
+object MDNSendSerializationTest {
+  private val FACTORY:   MessageId.Factory = new TestMessageId.Factory
+
+  private val SERIALIZER: MDNSendSerializer = new MDNSendSerializer(FACTORY)
+
+  private val ACCOUNT_ID: AccountId = AccountId(id)
+
+}
+
+class MDNSendSerializationTest extends AnyWordSpec with Matchers {
+
+  "Deserialize MDNSendRequest" should {
+    "succeed" in {
+      val forEmailId: MessageId = FACTORY.fromString("1")
+      val mdn: MDNSendCreateRequest = MDNSendCreateRequest(
+        forEmailId = ForEmailIdField(forEmailId),
+        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")),
+        includeOriginalMessage = Some(IncludeOriginalMessageField(true)),
+        disposition = MDNDisposition(
+          actionMode = "manual-action",
+          sendingMode = "mdn-sent-manually",
+          `type` = "displayed"),
+        extensionFields = Some(Map(("EXTENSION-EXAMPLE","example.com"))))
+
+      val id: MDNSendId = Id.validate("k1546").right.get
+
+      val request : MDNSendRequest = MDNSendRequest(
+        accountId = ACCOUNT_ID,
+        identityId = IdentityId(Id.validate("I64588216").right.get),
+        send = Map(id -> SERIALIZER.serializeMDNRequest(mdn)),
+        onSuccessUpdateEmail = None
+      )
+
+      val mdnSendRequestActual = SERIALIZER.deserializeMDNSendRequest(
+        """{
+          |  "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"
+          |      }
+          |    }
+          |  }
+          |}""".stripMargin)
+
+      mdnSendRequestActual should equal(JsSuccess(request))
+    }
+  }
+
+
+  "Serialize MDNSendResponse" should {
+    "succeed" 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,
+        extensionFields = Some(Map(("EXTENSION-EXAMPLE", "example.com"))),
+        includeOriginalMessage = Some(IncludeOriginalMessageField(false))
+      )
+
+      val idSent: MDNSendId = Id.validate("k1546").right.get
+      val idNotSent: MDNSendId = Id.validate("k01").right.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 = SERIALIZER.serializeMDNSendResponse(response)
+
+      val expectedValue = Json.prettyPrint(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,
+          |      "extensionFields" : {
+          |        "EXTENSION-EXAMPLE" : "example.com"
+          |      }
+          |    }
+          |  },
+          |  "notSent" : {
+          |    "k01" : {
+          |      "type" : "mdnAlreadySent",
+          |      "description" : "mdnAlreadySent description"
+          |    }
+          |  }
+          |}""".stripMargin))
+
+      printf(Json.prettyPrint(actualValue))

Review comment:
       Remove debug statements

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().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),
+      extensionFields = requestEntry.extensionFields match {
+        case Some(_) => None
+        case None => Option(mdn.getReport.getExtensionFields.asScala
+          .map(extension => (extension.getFieldName, extension.getRawValue))
+          .toMap).filter(_.nonEmpty)
+      },
+      finalRecipient = requestEntry.finalRecipient match {
+        case Some(_) => None
+        case None => Some(FinalRecipientField(mdn.getReport.getFinalRecipientField.fieldValue()))
+      })
+
+  private def getOriginalMessage(messageRelated: MessageResult): Message =
+    Message.Builder.of(messageRelated.getBody.getInputStream).build() //todo
+
+  private def getRecipientAddress(messageRelated: MessageResult, session: MailboxSession): String =

Review comment:
       Have a look at JMAPMDN.java getSenderAddress method

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().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),
+      extensionFields = requestEntry.extensionFields match {
+        case Some(_) => None
+        case None => Option(mdn.getReport.getExtensionFields.asScala
+          .map(extension => (extension.getFieldName, extension.getRawValue))
+          .toMap).filter(_.nonEmpty)
+      },
+      finalRecipient = requestEntry.finalRecipient match {
+        case Some(_) => None
+        case None => Some(FinalRecipientField(mdn.getReport.getFinalRecipientField.fieldValue()))
+      })
+
+  private def getOriginalMessage(messageRelated: MessageResult): Message =
+    Message.Builder.of(messageRelated.getBody.getInputStream).build() //todo

Review comment:
       ```
   
           DefaultMessageBuilder messageBuilder = new DefaultMessageBuilder();
           messageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE);
           messageBuilder.setDecodeMonitor(DecodeMonitor.SILENT);
           return messageBuilder.parseMessage(messageRelated.getHeaders().getInputStream());
   ```
   
   Is the best way to do it ;-)

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().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),
+      extensionFields = requestEntry.extensionFields match {

Review comment:
       Why would we have extension field if the client did not specify it?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().getSubject))

Review comment:
       We should not call `asMimeMessage` several time, this is expensive. You may need more arguments then...




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r616557867



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,316 @@
+/****************************************************************
+ * 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.core.MailAddress
+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, 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 identifyResolver: IdentifyResolver,
+                              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] = {
+    identifyResolver.resolveIdentityId(request.identityId, mailboxSession)
+      .flatMap(maybeIdentity => if (maybeIdentity.isEmpty) {
+        SMono.raiseError(IdentityIdNotFoundException("The IdentityId cannot be found"))
+      } else {
+        create(maybeIdentity.get, request, mailboxSession, invocation.processingContext)
+      })
+      .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 = getOriginalMessage(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[Exception, (MailImpl, MDNSendCreateResponse)] =
+    for {
+      mailRecipient <- getMailRecipient(originalMessage)
+      mdnFinalRecipient <- getMDNFinalRecipient(requestEntry, identity)
+      mdn = buildMDN(requestEntry, originalMessage, mdnFinalRecipient)
+      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[MDNSendForbiddenFromException, FinalRecipient] =
+    requestEntry.finalRecipient
+      .map(finalRecipient => finalRecipient.getMailAddress)
+      .map(mayBeMailAddress => (mayBeMailAddress.isSuccess && mayBeMailAddress.get.equals(identity.email)))
+      .map {
+        case true => scala.Right(requestEntry.finalRecipient.get.asJava.get)
+        case false => Left(MDNSendForbiddenFromException("The user is not allowed to use the given \"finalRecipient\" property"))
+      }
+      .getOrElse(scala.Right(FinalRecipient.builder()
+        .finalRecipient(Text.fromRawText(identity.email.asString()))
+        .build()))
+
+  private def buildMDN(requestEntry: MDNSendCreateRequest, originalMessage: Message, finalRecipient: FinalRecipient): MDN = {
+    val reportBuilder: MDNReport.Builder = MDNReport.builder()
+      .dispositionField(requestEntry.disposition.asJava.get)
+      .finalRecipientField(finalRecipient)
+      .originalRecipientField(originalMessage.getTo.asScala.head.toString)

Review comment:
       I don't feel sure about this. 
   What happened if `originalMessage.getTo` has multi-element?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa merged pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa merged pull request #385:
URL: https://github.com/apache/james-project/pull/385


   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r612051078



##########
File path: mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java
##########
@@ -60,6 +62,17 @@ public Builder userAgentProduct(String userAgentProduct) {
             return this;
         }
 
+        public Builder parse(String value) {
+            var list = ImmutableList.copyOf(Splitter.on("; ").omitEmptyStrings().split(value));

Review comment:
       After reading the whole changeset, I think this method ReportingUserAgent::parse might not be even needed...




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r611441321



##########
File path: mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java
##########
@@ -60,6 +62,17 @@ public Builder userAgentProduct(String userAgentProduct) {
             return this;
         }
 
+        public Builder parse(String value) {
+            var list = ImmutableList.copyOf(Splitter.on("; ").omitEmptyStrings().split(value));

Review comment:
       We don't use var in java.

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
##########
@@ -0,0 +1,216 @@
+/****************************************************************
+ * 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 eu.timepit.refined.refineV
+import org.apache.james.jmap.core.Id.{Id, IdConstraint}
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{AccountId, SetError}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+
+object MDNSend {
+  type MDNSendId = Id
+}

Review comment:
       Please use `case class MDNId(Id id)` instead of a type.

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
##########
@@ -0,0 +1,216 @@
+/****************************************************************
+ * 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 eu.timepit.refined.refineV
+import org.apache.james.jmap.core.Id.{Id, IdConstraint}
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{AccountId, SetError}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+
+object MDNSend {
+  type MDNSendId = Id
+}
+
+object MDNSendParseException {
+  def parse(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]) : MDNSendParseException = {
+    val 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'"))
+    }
+    MDNSendParseException(setError)
+  }
+}
+case class MDNSendParseException(error: SetError) extends Exception
+case class MDNSendForEmailIdNotFoundException(description: String) extends Exception
+case class MDNSendForbiddenException() extends Exception
+case class MDNSendForbiddenFromException() 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 MDNGatewayField(value: String) extends AnyVal
+
+object MDNSendCreateRequest {
+  private val assignableProperties: Set[String] = Set("forEmailId", "subject", "textBody", "reportingUA",
+    "finalRecipient", "includeOriginalMessage", "disposition", "extensionFields")
+
+  def validateProperties(jsObject: JsObject): Either[MDNSendParseException, JsObject] =
+    jsObject.keys.diff(assignableProperties) match {
+      case unknownProperties if unknownProperties.nonEmpty =>
+        Left(MDNSendParseException(SetError.invalidArguments(
+          SetErrorDescription("Some unknown properties were specified"),
+          Some(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[String, String]]) {
+  def validate : Either[MDNSendParseException, MDNSendCreateRequest] =
+    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],
+                                 error: Option[Seq[ErrorField]],
+                                 extensionFields: Option[Map[String, String]])
+
+case class IdentityId(id: Id)

Review comment:
       This IdentityId stuff already exist, do not duplicate it.

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDNSend.scala
##########
@@ -0,0 +1,216 @@
+/****************************************************************
+ * 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 eu.timepit.refined.refineV
+import org.apache.james.jmap.core.Id.{Id, IdConstraint}
+import org.apache.james.jmap.core.SetError.SetErrorDescription
+import org.apache.james.jmap.core.{AccountId, SetError}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.method.WithAccountId
+import org.apache.james.mailbox.model.MessageId
+import play.api.libs.json.{JsObject, JsPath, JsonValidationError}
+
+object MDNSend {
+  type MDNSendId = Id
+}
+
+object MDNSendParseException {
+  def parse(errors: collection.Seq[(JsPath, collection.Seq[JsonValidationError])]) : MDNSendParseException = {
+    val 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'"))
+    }
+    MDNSendParseException(setError)
+  }
+}
+case class MDNSendParseException(error: SetError) extends Exception
+case class MDNSendForEmailIdNotFoundException(description: String) extends Exception
+case class MDNSendForbiddenException() extends Exception
+case class MDNSendForbiddenFromException() 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 MDNGatewayField(value: String) extends AnyVal
+
+object MDNSendCreateRequest {
+  private val assignableProperties: Set[String] = Set("forEmailId", "subject", "textBody", "reportingUA",
+    "finalRecipient", "includeOriginalMessage", "disposition", "extensionFields")
+
+  def validateProperties(jsObject: JsObject): Either[MDNSendParseException, JsObject] =
+    jsObject.keys.diff(assignableProperties) match {
+      case unknownProperties if unknownProperties.nonEmpty =>
+        Left(MDNSendParseException(SetError.invalidArguments(
+          SetErrorDescription("Some unknown properties were specified"),
+          Some(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[String, String]]) {

Review comment:
       `String, String` ? Can we get strong types?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/package.scala
##########
@@ -0,0 +1,15 @@
+package org.apache.james.jmap

Review comment:
       Missing license

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/MDNSendSerializer.scala
##########
@@ -0,0 +1,86 @@
+/****************************************************************
+ * 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.MDNSend.MDNSendId
+import org.apache.james.jmap.mail.{ErrorField, FinalRecipientField, ForEmailIdField, IdentityId, IncludeOriginalMessageField, MDNDisposition, MDNGatewayField, MDNSendCreateRequest, MDNSendCreateResponse, 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 MDNSendSerializer @Inject()(messageIdFactory: MessageId.Factory) {

Review comment:
       Can we reuse MDNParseSerializer? Maybe rename it MDNSerializer...

##########
File path: 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,619 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *  http://www.apache.org/licenses/LICENSE-2.0                  *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder, _}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.nio.charset.StandardCharsets
+
+trait MDNSendMethodContract {
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  def randomMessageId: MessageId
+
+  @Test
+  def sendShouldBeSuccessWhenRequestIsValid(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(s"""{
+                    |    "sessionState": "${SESSION_STATE.value}",
+                    |    "methodResponses": [
+                    |        [
+                    |            "MDN/send",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "sent": {
+                    |                    "k1546": {
+                    |                        "finalRecipient": "rfc822; bob@domain.tld",
+                    |                        "includeOriginalMessage": false
+                    |                    }
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ],
+                    |        [
+                    |            "Email/set",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "oldState": "23",
+                    |                "newState": "42",
+                    |                "updated": {
+                    |                    "${emailIdRelated.serialize()}": null
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ]
+                    |    ]
+                    |}""".stripMargin)

Review comment:
       Do the original sender receives the MDN? (can we add a test)




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r615749835



##########
File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNSendMethodContract.scala
##########
@@ -130,7 +130,7 @@ trait MDNSendMethodContract {
          |      "MDN/send",
          |      {
          |        "accountId": "$ANDRE_ACCOUNT_ID",
-         |        "identityId": "I64588216",
+         |        "identityId": "$ANDRE_ACCOUNT_ID",

Review comment:
       You do put an accountId in an identityId? These are not the same...
   
   Edit: accountId and identityId happens to share the same format in our implementation.
   
   However we need 2 separate constant in our tests:
   
   ```
     val ANDRE_ACCOUNT_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
     val ANDRE_IDENTITY_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
   ```
   
   And use
   
   ```
   "accountId": "$ANDRE_ACCOUNT_ID",
   "identityId": "$ANDRE_IDENTITY_ID",
   ````
   
   here.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617439472



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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 mdnSendShouldReturnNotFoundWhenIdentityDoesNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+        .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [[
+         |            "error",
+         |            {
+         |                "type": "accountNotFound"
+         |            },
+         |            "c1"
+         |        ]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailDoesNotReferenceACreationWithinThisCall(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "error",
+           |            {
+           |                "type": "invalidArguments",
+           |                "description": "#notReference cannot be referenced in current method call"
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)

Review comment:
       Why this tag?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-821296496


   Can you write a test for  something similar to https://github.com/apache/james-project/pull/394 ?
   
   So when there is no onSuccessUpdateEmail property, no implicit call to Email/set is performed...


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] Arsnael commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
Arsnael commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r618200304



##########
File path: 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,1796 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(ANDRE.asString(), andreMailBoxPath, AppendCommand.builder()
+        .build(buildOriginalMessage("1")))
+      .getMessageId
+
+    val mdnSendRequest: 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": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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; ${BOB.asString}",
+           |                        "includeOriginalMessage": false,
+           |                        "originalRecipient": "rfc822; ${BOB.asString()}"
+           |                    }
+           |                }
+           |            },
+           |            "c1"
+           |        ],
+           |        [
+           |            "Email/set",
+           |            {
+           |                "accountId": "$ANDRE_ACCOUNT_ID",
+           |                "oldState": "23",
+           |                "newState": "42",
+           |                "updated": {
+           |                    "${emailIdRelated.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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)

Review comment:
       We only need to redefine the `baseRequestSpecBuilder` when we want to call jmap with an other user than the one defined in the setup for the requestSpecification (here BOB). 
   
   You can do here for example: 
   ```
   `given`()
       .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
       .body(requestQueryMDNMessage)
       .post()
   .then()
   [...]
   ```

##########
File path: 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,1796 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))

Review comment:
       Why not putting ANDRE user here actually after thinking?
   
   It seems you are making calls with the ANDRE user in all your tests, while you make maybe only one or two calls with BOB.
   
   Then you could apply my above comment a bit everywhere, allowing you reduce a bit the number of lines here




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617438686



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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 mdnSendShouldReturnNotFoundWhenIdentityDoesNotExist(guiceJamesServer: GuiceJamesServer): Unit = {

Review comment:
       ```suggestion
     def mdnSendShouldReturnInvalidWhenIdentityDoesNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
   ```




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-822161301


   I have some question:
   1. RFC write the IdentityId used to define a final recipient. But I don't find any reference about IdentityId on the source code. So, How I can define it?
   2. How I can control about rate limit (or over quota)? RFC defined this case:
   ```
   Too many MDNs or email messages have been created recently, and a server-defined rate limit has been reached. It may work if tried again later.
   ```
   Which component in James does that?
   3. I have an `MDNSendMethod.recordCreationIdInProcessingContext`, but I don't sure it is necessary. Should I remove this?


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] rouazana commented on pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
rouazana commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-824628427


   So nice to see this taking shape. Thanks @vttranlina for the implementation!


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r611679708



##########
File path: mdn/src/main/java/org/apache/james/mdn/fields/ReportingUserAgent.java
##########
@@ -60,6 +62,17 @@ public Builder userAgentProduct(String userAgentProduct) {
             return this;
         }
 
+        public Builder parse(String value) {
+            var list = ImmutableList.copyOf(Splitter.on("; ").omitEmptyStrings().split(value));

Review comment:
       Do you know the other way to split `value` to Tuple(userAgentName, userAgentProduct)?
   The manual check ( >=1 or >=2) is lengthy




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r616739339



##########
File path: server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
##########
@@ -105,7 +105,6 @@ protected void configure() {
         methods.addBinding().to(ThreadGetMethod.class);
         methods.addBinding().to(VacationResponseGetMethod.class);
         methods.addBinding().to(VacationResponseSetMethod.class);
-        methods.addBinding().to(MDNParseMethod.class);
         methods.addBinding().to(MDNSendMethod.class);

Review comment:
       Can we order this method alphabetically too?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina edited a comment on pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina edited a comment on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-822161301


   I have some question:
   1. RFC write the IdentityId used to define a final recipient. But I don't find any reference about IdentityId on the source code. So, How I can define it?
   2. How I can control about rate limit (or over quota)? RFC defined this case:
   ```
   Too many MDNs or email messages have been created recently, and a server-defined rate limit has been reached. It may work if tried again later.
   ```
   Which component in James does that?
   ref: https://www.rfc-editor.org/rfc/rfc9007.html#name-mdn-send
   3. I have an `MDNSendMethod.recordCreationIdInProcessingContext`, but I don't sure it is necessary. Should I remove this?
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617439122



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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."

Review comment:
       I believe a invalidArgument would be a better fit.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r615750464



##########
File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNSendMethodContract.scala
##########
@@ -1275,6 +1338,95 @@ trait MDNSendMethodContract {
                     |}""".stripMargin)
   }
 
+  @Test
+  def mdnSendShouldReturnNotFoundWhenIdentityIdIsNotExits(guiceJamesServer: GuiceJamesServer): Unit = {

Review comment:
       WhenIdentityDoesNotExist

##########
File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNSendMethodContract.scala
##########
@@ -1275,6 +1338,95 @@ trait MDNSendMethodContract {
                     |}""".stripMargin)
   }
 
+  @Test
+  def mdnSendShouldReturnNotFoundWhenIdentityIdIsNotExits(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 emailIdRelated: MessageId = mailboxProbe

Review comment:
       relatedEmailId (here and in other places?)

##########
File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNSendMethodContract.scala
##########
@@ -130,7 +130,7 @@ trait MDNSendMethodContract {
          |      "MDN/send",
          |      {
          |        "accountId": "$ANDRE_ACCOUNT_ID",
-         |        "identityId": "I64588216",
+         |        "identityId": "$ANDRE_ACCOUNT_ID",

Review comment:
       You do put an accountId in an identityId? These are not the same...
   
   Edit accountId and identityId happens to share the same format in our implementation.
   
   However we need 2 separate constant in our tests:
   
   ```
     val ANDRE_ACCOUNT_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
       val ANDRE_IDENTITY_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
   ```
   
   And use
   
   ```
   "accountId": "$ANDRE_ACCOUNT_ID",
   "identityId": "$ANDRE_IDENTITY_ID",
   ````
   
   here.

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentityGetMethod.scala
##########
@@ -66,3 +67,11 @@ class IdentityGetMethod @Inject() (identityFactory: IdentityFactory,
     SMono.fromCallable(() => identityFactory.listIdentities(mailboxSession))
       .map(request.computeResponse)
 }
+
+case class IdentifyStore @Inject()(identityFactory: IdentityFactory) {

Review comment:
       I would prefer `IdentifyResolver`

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -209,15 +212,27 @@ class MDNSendMethod @Inject()(serializer: MDNSerializer,
       .map(mailbox => mailbox.getAddress)
       .toRight(MDNSendNotFoundException("Invalid \"Disposition-Notification-To\" header field."))
 
-  private def buildMDN(requestEntry: MDNSendCreateRequest, originalMessage: Message, originalRecipientAddress: String): MDN = {
+  private def getMDNFinalRecipient(requestEntry: MDNSendCreateRequest, identity: Identity): Either[MDNSendForbiddenFromException, FinalRecipient] = {

Review comment:
       With a real mastery of Option and EIther monads, not a single i is required in this method. Can you do the refactoring?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r616738650



##########
File path: mdn/src/main/java/org/apache/james/mdn/MDN.java
##########
@@ -206,9 +208,13 @@ public MimeMultipart asMultipart() throws MessagingException {
         multipart.setReportType(DISPOSITION_NOTIFICATION_REPORT_TYPE);
         multipart.addBodyPart(computeHumanReadablePart());
         multipart.addBodyPart(computeReportPart());
-        if (message.isPresent()) {
-            multipart.addBodyPart(computeOriginalMessagePart());
-        }
+        message.ifPresent(message1 -> {
+            try {
+                multipart.addBodyPart(computeOriginalMessagePart(message1));
+            } catch (MessagingException e) {
+                e.printStackTrace();

Review comment:
       Debug spotted.
   
   We would write it
   
   ```
   message.ifPresent(Throwing.consumer(originalMessage -> multipart.addBodyPart(computeOriginalMessagePart(originalMessage)).sneakyThrow());
   ```
   
   To have the exception unchecked in the lambda...
   




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-823980484


   Maybe my last little remarks ;-)


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-818435430


   > Did you run your tests locally? Bunch of errors, that seem easy to fix. For example:
   > 
   
   Sorry, This pull request still in progress, not yet for test coverage


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617439472



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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 mdnSendShouldReturnNotFoundWhenIdentityDoesNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+        .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [[
+         |            "error",
+         |            {
+         |                "type": "accountNotFound"
+         |            },
+         |            "c1"
+         |        ]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailDoesNotReferenceACreationWithinThisCall(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "error",
+           |            {
+           |                "type": "invalidArguments",
+           |                "description": "#notReference cannot be referenced in current method call"
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)

Review comment:
       (This is a question) Why this tag? Did you use it to run only a group of tests in your IDE?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r612051418



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().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),
+      extensionFields = requestEntry.extensionFields match {

Review comment:
       Here I think doing more Test Driven Development would be saving you quite a bunch of code...




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617646380



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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."

Review comment:
       But, The RFC has been defined:
   ```
   notFound:
   The reference "forEmailId" cannot be found or has no valid "Disposition-Notification-To" header field
   ```




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r611672267



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)

Review comment:
       Spec don't define that case. 




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r618273189



##########
File path: 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,1796 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))

Review comment:
       (y) 




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r616378581



##########
File path: mdn/src/main/java/org/apache/james/mdn/MDN.java
##########
@@ -205,6 +206,10 @@ public MimeMultipart asMultipart() throws MessagingException {
         multipart.setReportType(DISPOSITION_NOTIFICATION_REPORT_TYPE);
         multipart.addBodyPart(computeHumanReadablePart());
         multipart.addBodyPart(computeReportPart());
+        if (message.isPresent()) {
+            multipart.addBodyPart(computeOriginalMessagePart());
+        }

Review comment:
       ```suggestion
          message.ifPresent(originalMessage -> multipart.addBodyPart(computeOriginalMessagePart(originalMessage)))
   ```

##########
File path: mdn/src/main/java/org/apache/james/mdn/MDN.java
##########
@@ -233,6 +238,17 @@ public BodyPart computeReportPart() throws MessagingException {
         return mdnPart;
     }
 
+    public BodyPart computeOriginalMessagePart() throws MessagingException {
+        Preconditions.checkState(message.isPresent());
+        MimeBodyPart originalMessagePart = new MimeBodyPart();
+        try {
+            originalMessagePart.setText(new String(DefaultMessageWriter.asBytes(message.get()), StandardCharsets.UTF_8));

Review comment:
       I would prefer:
   
   ```suggestion
               originalMessagePart.setContent(new String(DefaultMessageWriter.asBytes(message.get()), StandardCharsets.UTF_8), "message/rfc822");
   ```
   
   Do we have unit tests for this method?

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala
##########
@@ -0,0 +1,131 @@
+/****************************************************************
+ * 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 valid: Either[MDNSendRequestInvalidException, ReportUAField] =

Review comment:
       ```suggestion
     def validate: Either[MDNSendRequestInvalidException, ReportUAField] =
   ```

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,316 @@
+/****************************************************************
+ * 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.core.MailAddress
+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, 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 identifyResolver: IdentifyResolver,
+                              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] = {
+    identifyResolver.resolveIdentityId(request.identityId, mailboxSession)
+      .flatMap(maybeIdentity => if (maybeIdentity.isEmpty) {
+        SMono.raiseError(IdentityIdNotFoundException("The IdentityId cannot be found"))
+      } else {
+        create(maybeIdentity.get, request, mailboxSession, invocation.processingContext)
+      })

Review comment:
       ```suggestion
         .flatMap(maybeIdentity =>maybeIdentity.map(identity => create(identity, request, mailboxSession, invocation.processingContext))
             .getOrElse(SMono.raiseError(IdentityIdNotFoundException("The IdentityId cannot be found")))
   ```
   
   Calling .get is forbidden ;-)

##########
File path: server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/rfc8621/RFC8621MethodsModule.java
##########
@@ -103,6 +105,8 @@ protected void configure() {
         methods.addBinding().to(ThreadGetMethod.class);
         methods.addBinding().to(VacationResponseGetMethod.class);
         methods.addBinding().to(VacationResponseSetMethod.class);
+        methods.addBinding().to(MDNParseMethod.class);

Review comment:
       MDNParse method is already added line 103.
   
   Also please respect alphabetic ordering.

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNParseMethod.scala
##########
@@ -67,7 +68,7 @@ class MDNParseMethod @Inject()(val blobResolvers: BlobResolvers,
     computeResponse(request, mailboxSession)
       .map(res => Invocation(
         methodName,
-        Arguments(MDNParseSerializer.serialize(res).as[JsObject]),
+        Arguments(serializer.serializeMDNParseResponse(res).as[JsObject]),

Review comment:
       res => response

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,316 @@
+/****************************************************************
+ * 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.core.MailAddress
+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, 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 identifyResolver: IdentifyResolver,
+                              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] = {
+    identifyResolver.resolveIdentityId(request.identityId, mailboxSession)
+      .flatMap(maybeIdentity => if (maybeIdentity.isEmpty) {
+        SMono.raiseError(IdentityIdNotFoundException("The IdentityId cannot be found"))
+      } else {
+        create(maybeIdentity.get, request, mailboxSession, invocation.processingContext)
+      })
+      .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 = getOriginalMessage(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[Exception, (MailImpl, MDNSendCreateResponse)] =
+    for {
+      mailRecipient <- getMailRecipient(originalMessage)
+      mdnFinalRecipient <- getMDNFinalRecipient(requestEntry, identity)
+      mdn = buildMDN(requestEntry, originalMessage, mdnFinalRecipient)
+      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[MDNSendForbiddenFromException, FinalRecipient] =
+    requestEntry.finalRecipient
+      .map(finalRecipient => finalRecipient.getMailAddress)
+      .map(mayBeMailAddress => (mayBeMailAddress.isSuccess && mayBeMailAddress.get.equals(identity.email)))
+      .map {
+        case true => scala.Right(requestEntry.finalRecipient.get.asJava.get)
+        case false => Left(MDNSendForbiddenFromException("The user is not allowed to use the given \"finalRecipient\" property"))
+      }
+      .getOrElse(scala.Right(FinalRecipient.builder()
+        .finalRecipient(Text.fromRawText(identity.email.asString()))
+        .build()))
+
+  private def buildMDN(requestEntry: MDNSendCreateRequest, originalMessage: Message, finalRecipient: FinalRecipient): MDN = {
+    val reportBuilder: MDNReport.Builder = MDNReport.builder()
+      .dispositionField(requestEntry.disposition.asJava.get)
+      .finalRecipientField(finalRecipient)
+      .originalRecipientField(originalMessage.getTo.asScala.head.toString)
+
+    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 getOriginalMessage(messageRelated: MessageResult): Message = {

Review comment:
       ```suggestion
     private def parseAsMimeMessage(messageRelated: MessageResult): Message = {
   ```

##########
File path: 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.valid
+      .fold(error => Left(error), _ => scala.Right(this))
+
+  def validateReportUA: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    reportingUA match {
+      case None => scala.Right(this)
+      case Some(value) => value.valid.fold(error => Left(error), _ => scala.Right(this))
+    }
+
+  def validateFinalRecipient: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    finalRecipient match {
+      case None => scala.Right(this)
+      case Some(value) => value.valid.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((sent1, sent2) => sent1 ++ sent2),
+    notSent = (result1.notSent ++ result2.notSent).reduceOption((notSent1, notSent2) => notSent1 ++ notSent2),

Review comment:
       ```suggestion
       notSent = (result1.notSent ++ result2.notSent).reduceOption(_ ++ _),
   ```

##########
File path: 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.valid
+      .fold(error => Left(error), _ => scala.Right(this))
+
+  def validateReportUA: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    reportingUA match {
+      case None => scala.Right(this)
+      case Some(value) => value.valid.fold(error => Left(error), _ => scala.Right(this))
+    }
+
+  def validateFinalRecipient: Either[MDNSendRequestInvalidException, MDNSendCreateRequest] =
+    finalRecipient match {
+      case None => scala.Right(this)
+      case Some(value) => value.valid.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((sent1, sent2) => sent1 ++ sent2),

Review comment:
       ```suggestion
       sent = (result1.sent ++ result2.sent).reduceOption(_ ++ _),
   ```

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala
##########
@@ -0,0 +1,131 @@
+/****************************************************************
+ * 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 valid: 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] = Try(new MailAddress(asJava.get.getFinalRecipient.formatted()))
+
+  def valid: 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 valid: Either[MDNSendRequestInvalidException, MDNDisposition] =

Review comment:
       ```suggestion
     def validate: Either[MDNSendRequestInvalidException, MDNDisposition] =
   ```

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala
##########
@@ -0,0 +1,131 @@
+/****************************************************************
+ * 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 valid: 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] = Try(new MailAddress(asJava.get.getFinalRecipient.formatted()))

Review comment:
       ```suggestion
     def getMailAddress: Try[MailAddress] = for {
         javaFinalRecipient <- asJava
         mailAddress <- new MailAddress(javaFinalRecipient.formatted())
     } yield mailAddress
   ```

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/MDN.scala
##########
@@ -0,0 +1,131 @@
+/****************************************************************
+ * 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 valid: 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] = Try(new MailAddress(asJava.get.getFinalRecipient.formatted()))
+
+  def valid: Either[MDNSendRequestInvalidException, FinalRecipientField] =

Review comment:
       ```suggestion
     def validate: Either[MDNSendRequestInvalidException, FinalRecipientField] =
   ```

##########
File path: mdn/src/main/java/org/apache/james/mdn/MDN.java
##########
@@ -233,6 +238,17 @@ public BodyPart computeReportPart() throws MessagingException {
         return mdnPart;
     }
 
+    public BodyPart computeOriginalMessagePart() throws MessagingException {

Review comment:
       ```suggestion
       public BodyPart computeOriginalMessagePart(Message message) throws MessagingException {
   ```

##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentityGetMethod.scala
##########
@@ -66,3 +67,11 @@ class IdentityGetMethod @Inject() (identityFactory: IdentityFactory,
     SMono.fromCallable(() => identityFactory.listIdentities(mailboxSession))
       .map(request.computeResponse)
 }
+
+case class IdentifyResolver @Inject()(identityFactory: IdentityFactory) {

Review comment:
       ```suggestion
   case class IdentityResolver @Inject()(identityFactory: IdentityFactory) {
   ```




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina edited a comment on pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina edited a comment on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-822161301


   I have some question:
   1. RFC write the IdentityId used to define a final recipient. But I don't find any reference about IdentityId on the source code. So, How I can define it?
   2. How I can control about rate limit (or over quota)? RFC defined this case:
   ```
   Too many MDNs or email messages have been created recently, and a server-defined rate limit has been reached. It may work if tried again later.
   ```
   ref: https://www.rfc-editor.org/rfc/rfc9007.html#name-mdn-send
   Which component in James does that?
   3. I have an `MDNSendMethod.recordCreationIdInProcessingContext`, but I don't sure it is necessary. Should I remove this?
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r615539997



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))

Review comment:
       May you write an example?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-824628256


   rebase code


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r615749835



##########
File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MDNSendMethodContract.scala
##########
@@ -130,7 +130,7 @@ trait MDNSendMethodContract {
          |      "MDN/send",
          |      {
          |        "accountId": "$ANDRE_ACCOUNT_ID",
-         |        "identityId": "I64588216",
+         |        "identityId": "$ANDRE_ACCOUNT_ID",

Review comment:
       You do put an accountId in an identityId? These are not the same...
   
   Edit: accountId and identityId happens to share the same format in our implementation.
   
   However we need 2 separate constant in our tests:
   
   ```
     val ANDRE_ACCOUNT_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
       val ANDRE_IDENTITY_ID: String = "1e8584548eca20f26faf6becc1704a0f352839f12c208a47fbd486d60f491f7c"
   ```
   
   And use
   
   ```
   "accountId": "$ANDRE_ACCOUNT_ID",
   "identityId": "$ANDRE_IDENTITY_ID",
   ````
   
   here.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r615540209



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)

Review comment:
       I omit the Message-Id field. 




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r611670342



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)
+
+    val mdnSendCreateResponse = buildMDNSendCreateResponse(requestEntry, mdn)
+    (MailImpl.fromMimeMessage(newMessageId, mimeMessage) -> mdnSendCreateResponse)
+  }
+
+  private def buildMDNSendCreateResponse(requestEntry: MDNSendCreateRequest, mdn: MDN) =
+    MDNSendCreateResponse(
+      subject = requestEntry.subject match {
+        case Some(_) => None
+        case None => Some(SubjectField(mdn.asMimeMessage().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),
+      extensionFields = requestEntry.extensionFields match {

Review comment:
       I can't differentiate this field is `server-set`(server evaluate) or not.
   So, This is my reserve.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617440379



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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 mdnSendShouldReturnNotFoundWhenIdentityDoesNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+        .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [[
+         |            "error",
+         |            {
+         |                "type": "accountNotFound"
+         |            },
+         |            "c1"
+         |        ]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailDoesNotReferenceACreationWithinThisCall(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "error",
+           |            {
+           |                "type": "invalidArguments",
+           |                "description": "#notReference cannot be referenced in current method call"
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)
+  @Test
+  def mdnSendShouldReturnSubjectWhenRequestDoNotSet(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+      baseRequestSpecBuilder(guiceJamesServer)
+        .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+        .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+        .setBody(mdnSendRequest)
+        .build, new ResponseSpecBuilder().build)
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    awaitAtMostTenSeconds.untilAsserted { () =>
+      val mdnBodyPartCounter = getFirstMessageInMailBox(guiceJamesServer, BOB)
+        .filter(msg => msg.isMultipart)
+        .map(msg => msg.getBody.asInstanceOf[Multipart].getBodyParts)
+      assert(mdnBodyPartCounter.isDefined && mdnBodyPartCounter.get.size > 2)

Review comment:
       I think we could say `== 3` here, no?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r617644807



##########
File path: 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,1948 @@
+/****************************************************************
+ * 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.builder.ResponseSpecBuilder
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.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
+
+  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(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessAndSendMailSuccessfully(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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`(
+          baseRequestSpecBuilder(guiceJamesServer)
+            .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+            .setBody(requestQueryMDNMessage)
+            .build, new ResponseSpecBuilder().build)
+          .post
+        .`then`
+          .statusCode(SC_OK)
+          .contentType(JSON)
+          .extract
+          .body
+          .asString
+
+      assertThatJson(response)
+        .inPath("methodResponses[0][1].ids")
+        .isArray
+        .hasSize(1)
+    }
+  }
+
+  @Test
+  def mdnSendShouldBeSuccessWhenRequestAssignFinalRecipient(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(mdnSendResponse)
+      .inPath("methodResponses[1]")
+      .isAbsent()
+  }
+
+  @Test
+  def implicitEmailSetShouldNotBeAttemptedWhenOnSuccessUpdateEmailIsNull(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 mdnSendRequest: 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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(mdnSendRequest)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .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(): 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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "notFound",
+                   |        "description": "The reference \"forEmailId\" cannot be found."
+                   |    }
+                   |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnNotFoundWhenMessageRelateHasNotDispositionNotificationTo(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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 mdnSendShouldReturnNotFoundWhenIdentityDoesNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`(
+        baseRequestSpecBuilder(guiceJamesServer)
+          .setAuth(authScheme(UserCredential(ANDRE, ANDRE_PASSWORD)))
+          .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+          .setBody(request)
+          .build, new ResponseSpecBuilder().build)
+        .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(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+        .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+        .body(request)
+      .when
+        .post
+      .`then`
+        .statusCode(SC_OK)
+        .contentType(JSON)
+        .extract
+        .body
+        .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [[
+         |            "error",
+         |            {
+         |                "type": "accountNotFound"
+         |            },
+         |            "c1"
+         |        ]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldBeFailWhenOnSuccessUpdateEmailDoesNotReferenceACreationWithinThisCall(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.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`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .isEqualTo(
+        s"""{
+           |    "sessionState": "${SESSION_STATE.value}",
+           |    "methodResponses": [
+           |        [
+           |            "error",
+           |            {
+           |                "type": "invalidArguments",
+           |                "description": "#notReference cannot be referenced in current method call"
+           |            },
+           |            "c1"
+           |        ]
+           |    ]
+           |}""".stripMargin)
+  }
+
+  @Tag(TAG_MDN_MESSAGE_FORMAT)

Review comment:
       No, I want to mark it. Grouping some test case with the same target.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] chibenwa commented on pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
chibenwa commented on pull request #385:
URL: https://github.com/apache/james-project/pull/385#issuecomment-822190733


   ```
   1. RFC write the IdentityId used to define a final recipient. But I don't find any reference about IdentityId on the source code. So, How I can define it?
   ```
   
   -> IdentityGetMethod
   
   We *may* need to extract a subclass `IdentifyStore` that resolves Identities, so that other methods can access it.
   
   ```
   2. How I can control about rate limit (or over quota)?
   ```
   
   Rate limiting is out of the scope of our current efforts to support JMAP. Do not try.
   
   Rate limiting in distributed systems is a complex topic. James is likely a bad place to enforce it.
   
   ```
   3. I have an MDNSendMethod.recordCreationIdInProcessingContext, but I don't sure it is necessary. Should I remove this?
   ```
   
   In the case of a `send` we do not keep a local copy of the MDN so from a JMAP perspective, we did not create anything. You can remove it (for MDN/send only)


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] LanKhuat commented on a change in pull request #385: [WIP] JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
LanKhuat commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r612931217



##########
File path: server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/MDNSendSerializationTest.scala
##########
@@ -0,0 +1,152 @@
+/****************************************************************
+ * 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.MDNSendSerializationTest.{ACCOUNT_ID, FACTORY, SERIALIZER}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+import org.apache.james.jmap.mail.{FinalRecipientField, ForEmailIdField, IdentityId, IncludeOriginalMessageField, MDNDisposition, MDNGatewayField, MDNSendCreateRequest, MDNSendCreateResponse, MDNSendRequest, MDNSendResponse, OriginalMessageIdField, OriginalRecipientField, ReportUAField, SubjectField, TextBodyField}
+import org.apache.james.mailbox.model.{MessageId, TestMessageId}
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+import play.api.libs.json.{JsSuccess, Json}
+
+object MDNSendSerializationTest {
+  private val FACTORY:   MessageId.Factory = new TestMessageId.Factory

Review comment:
       extra spaces

##########
File path: 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,619 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *  http://www.apache.org/licenses/LICENSE-2.0                  *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder, _}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.nio.charset.StandardCharsets
+
+trait MDNSendMethodContract {
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  def randomMessageId: MessageId
+
+  @Test
+  def sendShouldBeSuccessWhenRequestIsValid(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(s"""{
+                    |    "sessionState": "${SESSION_STATE.value}",
+                    |    "methodResponses": [
+                    |        [
+                    |            "MDN/send",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "sent": {
+                    |                    "k1546": {
+                    |                        "finalRecipient": "rfc822; bob@domain.tld",
+                    |                        "includeOriginalMessage": false
+                    |                    }
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ],
+                    |        [
+                    |            "Email/set",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "oldState": "23",
+                    |                "newState": "42",
+                    |                "updated": {
+                    |                    "${emailIdRelated.serialize()}": null
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ]
+                    |    ]
+                    |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldBeErrorWhenMDNHasAlreadyBeenSet(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("Test subject")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("Original email, that mdn related", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "mdnAlreadySent",
+                   |        "description": "The message has the $mdnsent keyword already set."
+                   |    }
+                   |}""".stripMargin)
+
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingOneCapability(): Unit = {
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      s"""{
+         |  "using": [],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldGetNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${randomMessageId.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post.prettyPeek()

Review comment:
       Remember to remove debug when you are done

##########
File path: 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,619 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *  http://www.apache.org/licenses/LICENSE-2.0                  *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder, _}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.nio.charset.StandardCharsets
+
+trait MDNSendMethodContract {
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  def randomMessageId: MessageId
+
+  @Test
+  def sendShouldBeSuccessWhenRequestIsValid(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(s"""{
+                    |    "sessionState": "${SESSION_STATE.value}",
+                    |    "methodResponses": [
+                    |        [
+                    |            "MDN/send",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "sent": {
+                    |                    "k1546": {
+                    |                        "finalRecipient": "rfc822; bob@domain.tld",
+                    |                        "includeOriginalMessage": false
+                    |                    }
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ],
+                    |        [
+                    |            "Email/set",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "oldState": "23",
+                    |                "newState": "42",
+                    |                "updated": {
+                    |                    "${emailIdRelated.serialize()}": null
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ]
+                    |    ]
+                    |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldBeErrorWhenMDNHasAlreadyBeenSet(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("Test subject")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("Original email, that mdn related", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "mdnAlreadySent",
+                   |        "description": "The message has the $mdnsent keyword already set."
+                   |    }
+                   |}""".stripMargin)
+
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingOneCapability(): Unit = {
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      s"""{
+         |  "using": [],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldGetNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {

Review comment:
       shouldReturnNotFound?

##########
File path: 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,619 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *  http://www.apache.org/licenses/LICENSE-2.0                  *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder, _}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.nio.charset.StandardCharsets
+
+trait MDNSendMethodContract {
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  def randomMessageId: MessageId
+
+  @Test
+  def sendShouldBeSuccessWhenRequestIsValid(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(s"""{
+                    |    "sessionState": "${SESSION_STATE.value}",
+                    |    "methodResponses": [
+                    |        [
+                    |            "MDN/send",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "sent": {
+                    |                    "k1546": {
+                    |                        "finalRecipient": "rfc822; bob@domain.tld",
+                    |                        "includeOriginalMessage": false
+                    |                    }
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ],
+                    |        [
+                    |            "Email/set",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "oldState": "23",
+                    |                "newState": "42",
+                    |                "updated": {
+                    |                    "${emailIdRelated.serialize()}": null
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ]
+                    |    ]
+                    |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldBeErrorWhenMDNHasAlreadyBeenSet(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("Test subject")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("Original email, that mdn related", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "mdnAlreadySent",
+                   |        "description": "The message has the $mdnsent keyword already set."
+                   |    }
+                   |}""".stripMargin)
+
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingOneCapability(): Unit = {
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      s"""{
+         |  "using": [],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldGetNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${randomMessageId.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post.prettyPeek()
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(s"""[
+                    |    "MDN/send",
+                    |    {
+                    |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |        "notSent": {
+                    |            "k1546": {
+                    |                "type": "notFound",
+                    |                "description": "The reference \\"forEmailId\\" cannot be found."
+                    |            }
+                    |        }
+                    |    },
+                    |    "c1"
+                    |]""".stripMargin)
+  }
+
+  @Test
+  def setShouldFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def setShouldFailWhenOnSuccessDestroyEmailDoesNotReferenceACreationWithinThisCall(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post.prettyPeek()

Review comment:
       idem

##########
File path: 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,619 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *  http://www.apache.org/licenses/LICENSE-2.0                  *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured._
+import io.restassured.http.ContentType.JSON
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.SC_OK
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.ResponseObject.SESSION_STATE
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.rfc8621.contract.Fixture.{BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder, _}
+import org.apache.james.mailbox.MessageManager.AppendCommand
+import org.apache.james.mailbox.model.{MailboxPath, MessageId}
+import org.apache.james.mime4j.dom.Message
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.utils.DataProbeImpl
+import org.junit.jupiter.api.{BeforeEach, Test}
+
+import java.nio.charset.StandardCharsets
+
+trait MDNSendMethodContract {
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .build()
+  }
+
+  def randomMessageId: MessageId
+
+  @Test
+  def sendShouldBeSuccessWhenRequestIsValid(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[1][1].newState",
+        "methodResponses[1][1].oldState")
+      .isEqualTo(s"""{
+                    |    "sessionState": "${SESSION_STATE.value}",
+                    |    "methodResponses": [
+                    |        [
+                    |            "MDN/send",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "sent": {
+                    |                    "k1546": {
+                    |                        "finalRecipient": "rfc822; bob@domain.tld",
+                    |                        "includeOriginalMessage": false
+                    |                    }
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ],
+                    |        [
+                    |            "Email/set",
+                    |            {
+                    |                "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |                "oldState": "23",
+                    |                "newState": "42",
+                    |                "updated": {
+                    |                    "${emailIdRelated.serialize()}": null
+                    |                }
+                    |            },
+                    |            "c1"
+                    |        ]
+                    |    ]
+                    |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldBeErrorWhenMDNHasAlreadyBeenSet(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("Test subject")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("Original email, that mdn related", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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": {
+         |          "#k1546": {
+         |            "keywords/$$mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath(s"methodResponses[0][1].notSent")
+      .isEqualTo("""{
+                   |    "k1546": {
+                   |        "type": "mdnAlreadySent",
+                   |        "description": "The message has the $mdnsent keyword already set."
+                   |    }
+                   |}""".stripMargin)
+
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingOneCapability(): Unit = {
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def mdnSendShouldReturnUnknownMethodWhenMissingAllCapabilities(): Unit = {
+    val request =
+      s"""{
+         |  "using": [],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "ue150411c",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "Md45b47b4877521042cec0938",
+         |            "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"
+         |            },
+         |            "extension": {
+         |              "X-EXTENSION-EXAMPLE": "example.com"
+         |            }
+         |          }
+         |        },
+         |        "onSuccessUpdateEmail": {
+         |          "#k1546": {
+         |            "keywords/mdnsent": true
+         |          }
+         |        }
+         |      },
+         |      "c1"
+         |    ]
+         |  ]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "error",
+         |    {
+         |      "type": "unknownMethod",
+         |      "description": "Missing capability(ies): urn:ietf:params:jmap:mdn, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:core"
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
+  @Test
+  def sendShouldGetNotFoundWhenForEmailIdIsNotExist(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${randomMessageId.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 response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post.prettyPeek()
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(s"""[
+                    |    "MDN/send",
+                    |    {
+                    |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+                    |        "notSent": {
+                    |            "k1546": {
+                    |                "type": "notFound",
+                    |                "description": "The reference \\"forEmailId\\" cannot be found."
+                    |            }
+                    |        }
+                    |    },
+                    |    "c1"
+                    |]""".stripMargin)
+  }
+
+  @Test
+  def setShouldFailWhenOnSuccessUpdateEmailMissesTheCreationIdSharp(guiceJamesServer: GuiceJamesServer): Unit = {
+    val path: MailboxPath = MailboxPath.inbox(BOB)
+    val mailboxProbe: MailboxProbeImpl = guiceJamesServer.getProbe(classOf[MailboxProbeImpl])
+    mailboxProbe.createMailbox(path)
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setSender(BOB.asString)
+      .setFrom(BOB.asString)
+      .setTo(ANDRE.asString)
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val emailIdRelated: MessageId = mailboxProbe
+      .appendMessage(BOB.asString(), path, AppendCommand.builder()
+        .build(message))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail",
+         |    "urn:ietf:params:jmap:mdn"
+         |  ],
+         |  "methodCalls": [
+         |    [
+         |      "MDN/send",
+         |      {
+         |        "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |        "identityId": "I64588216",
+         |        "send": {
+         |          "k1546": {
+         |            "forEmailId": "${emailIdRelated.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 = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0]")
+      .isEqualTo(
+        s"""[
+           |    "error",
+           |    {
+           |        "type": "invalidArguments",
+           |        "description": "notStored cannot be retrieved as storage for MDNSend is not yet implemented"
+           |    },
+           |    "c1"
+           |]""".stripMargin)
+  }
+
+  @Test
+  def setShouldFailWhenOnSuccessDestroyEmailDoesNotReferenceACreationWithinThisCall(guiceJamesServer: GuiceJamesServer): Unit = {

Review comment:
       Is it OnSuccessDestroyEmail or OnSuccessUpdateEmail? Title and content does not match.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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


[GitHub] [james-project] vttranlina commented on a change in pull request #385: JAMES-3520 MDN/send

Posted by GitBox <gi...@apache.org>.
vttranlina commented on a change in pull request #385:
URL: https://github.com/apache/james-project/pull/385#discussion_r615540413



##########
File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/MDNSendMethod.scala
##########
@@ -0,0 +1,262 @@
+/****************************************************************
+ * 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.{ClientId, Id, Invocation, ServerId}
+import org.apache.james.jmap.json.{MDNSendSerializer, ResponseSerializer}
+import org.apache.james.jmap.mail.MDNSend.MDNSendId
+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, MessageId, MessageResult}
+import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
+import org.apache.james.mdn.`type`.DispositionType
+import org.apache.james.mdn.action.mode.DispositionActionMode
+import org.apache.james.mdn.fields.{ExtensionField, ReportingUserAgent, Disposition => DispositionJava}
+import org.apache.james.mdn.sending.mode.DispositionSendingMode
+import org.apache.james.mdn.{MDN, MDNReport}
+import org.apache.james.metrics.api.MetricFactory
+import org.apache.james.mime4j.dom.Message
+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 scala.jdk.CollectionConverters._
+import scala.jdk.OptionConverters._
+import scala.util.Try
+
+class MDNSendMethod @Inject()(serializer: MDNSendSerializer,
+                              mailQueueFactory: MailQueueFactory[_ <: MailQueue],
+                              messageIdManager: MessageIdManager,
+                              emailSetMethod: EmailSetMethod,
+                              messageIdFactory: MessageId.Factory,
+                              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] =
+    create(request, mailboxSession, invocation.processingContext)
+      .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),
+            emailSetRequest => emailSetMethod.doProcess(
+              capabilities = capabilities,
+              invocation = invocation,
+              mailboxSession = mailboxSession,
+              request = emailSetRequest))
+
+        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(request: MDNSendRequest,
+                     session: MailboxSession,
+                     processingContext: ProcessingContext): SMono[(MDNSendResults, ProcessingContext)] =
+    SFlux.fromIterable(request.send.view)
+      .fold(MDNSendResults.empty -> processingContext) {
+        (acc: (MDNSendResults, ProcessingContext), elem: (MDNSendId, JsObject)) => {
+          val (mdnSendId, jsObject) = elem
+          val (creationResult, updatedProcessingContext) = createMDNSend(session, mdnSendId, jsObject, acc._2)
+          (MDNSendResults.merge(acc._1, creationResult) -> updatedProcessingContext)
+        }
+      }
+      .subscribeOn(Schedulers.elastic())
+
+  private def createMDNSend(session: MailboxSession,
+                            mdnSendId: MDNSendId,
+                            jsObject: JsObject,
+                            processingContext: ProcessingContext): (MDNSendResults, ProcessingContext) =
+    parseMDNRequest(jsObject)
+      .flatMap(createRequest => sendMDN(session, mdnSendId, createRequest))
+      .flatMap {
+        case (results, id) => recordCreationIdInProcessingContext(mdnSendId, id, processingContext)
+          .map(_ => results)
+      }
+      .fold(error => (MDNSendResults.notSent(mdnSendId, error) -> processingContext),
+        creation => (creation -> processingContext))
+
+  private def parseMDNRequest(jsObject: JsObject): Either[MDNSendParseException, MDNSendCreateRequest] =
+      MDNSendCreateRequest.validateProperties(jsObject)
+        .flatMap(validJson => serializer.deserializeMDNSendCreateRequest(validJson) match {
+          case JsSuccess(createRequest, _) => createRequest.validate
+          case JsError(errors) => Left(MDNSendParseException.parse(errors))
+        })
+
+  private def sendMDN(session: MailboxSession,
+                      mdnSendId: MDNSendId,
+                      requestEntry: MDNSendCreateRequest): Either[Throwable, (MDNSendResults, MessageId)] = {
+    val mdnRelatedMessage: Either[Exception, MessageResult] = messageIdManager.getMessage(requestEntry.forEmailId.originalMessageId, FetchGroup.FULL_CONTENT, session)
+      .asScala
+      .toList
+      .headOption
+      .toRight(MDNSendForEmailIdNotFoundException("The reference \"forEmailId\" cannot be found."))
+
+    val mdnRelatedMessageAlready: Either[Exception, MessageResult] = mdnRelatedMessage.flatMap(messageResult => {
+      if (isMDNSentAlready(messageResult)) {
+        Left(MDNSendAlreadySentException())
+      } else {
+        scala.Right(messageResult)
+      }
+    })
+
+    mdnRelatedMessageAlready.flatMap(msg => {
+      val result: Try[(MDNSendResults, MessageId)] = {
+        val (mail, createResponse) = buildMailAndResponse(session, requestEntry, msg)
+        queue.enQueue(mail)
+        Try(MDNSendResults.sent(mdnSendId, createResponse, msg.getMessageId) -> msg.getMessageId)
+      }
+      result.toEither
+    })
+  }
+
+  private def isMDNSentAlready(messageResultRelated: MessageResult): Boolean =
+    messageResultRelated.getFlags.contains("$mdnsent")
+
+  private def recordCreationIdInProcessingContext(mdnSendId: MDNSendId,
+                                                  messageId: MessageId,
+                                                  processingContext: ProcessingContext): Either[IllegalArgumentException, ProcessingContext] =
+    for {
+      creationId <- Id.validate(mdnSendId)
+      serverAssignedId <- Id.validate(messageId.serialize())
+    } yield {
+      processingContext.recordCreatedId(ClientId(creationId), ServerId(serverAssignedId))
+    }
+
+  private def buildMailAndResponse(session: MailboxSession, requestEntry: MDNSendCreateRequest, messageRelated: MessageResult): (MailImpl, MDNSendCreateResponse) = {
+    val sender = session.getUser.asString()
+    val reportBuilder = MDNReport.builder()
+      .dispositionField(DispositionJava.builder()
+        .`type`(DispositionType.fromString(requestEntry.disposition.`type`).orElseThrow())
+        .actionMode(DispositionActionMode.fromString(requestEntry.disposition.actionMode).orElseThrow())
+        .sendingMode(DispositionSendingMode.fromString(requestEntry.disposition.sendingMode).orElseThrow())
+        .build())
+      .finalRecipientField(requestEntry.finalRecipient.getOrElse(FinalRecipientField(sender)).value)
+
+    requestEntry.reportingUA.map(uaField => reportBuilder.reportingUserAgentField(ReportingUserAgent.builder().parse(uaField.value).build()))
+
+    requestEntry.extensionFields.map(extensions => extensions
+      .map(extension => reportBuilder.withExtensionField(ExtensionField.builder()
+        .fieldName(extension._1)
+        .rawValue(extension._2)
+        .build())))
+
+    val mdnBuilder = MDN.builder()
+      .report(reportBuilder.build())
+      .message(requestEntry.includeOriginalMessage
+        .filter(isInclude => isInclude.value)
+        .map(_ => getOriginalMessage(messageRelated)).toJava)
+
+    requestEntry.textBody.map(textBody => mdnBuilder.humanReadableText(textBody.value))
+
+    val newMessageId = messageIdFactory.generate().serialize()
+    val mdn = mdnBuilder.build()
+    val mimeMessage = mdn.asMimeMessage()
+
+    mimeMessage.setFrom(sender)
+    mimeMessage.setRecipients(javax.mail.Message.RecipientType.TO, getRecipientAddress(messageRelated, session))
+    mimeMessage.setHeader("Message-Id", newMessageId)
+    mimeMessage.setSubject(requestEntry.subject.getOrElse(SubjectField("subject todo")).value)

Review comment:
       I reused the subject of the related message and prefix it with `Received] xxxxI`




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



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