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