This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 7153ba836fd12f67a161f823b0176b4b3a939761
Author: Benoit TELLIER <[email protected]>
AuthorDate: Thu Sep 10 13:01:18 2026 +0200

    JAMES-4228 ValidRcptEmailSubmissionSetValidation
---
 .../org/apache/james/jmap/core/SetError.scala      |  4 ++
 .../jmap/method/EmailSubmissionSetValidation.scala | 38 +++++++++++
 .../ValidRcptEmailSubmissionSetValidation.scala    | 77 ++++++++++++++++++++++
 3 files changed, 119 insertions(+)

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 4c2bcf4a6f..eaaec60e3f 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
@@ -39,6 +39,7 @@ object SetError {
   val mdnAlreadySentValue: SetErrorType = "mdnAlreadySent"
   val forbiddenFromValue: SetErrorType = "forbiddenFrom"
   val tooLargeValue: SetErrorType = "tooLarge"
+  val invalidRecipientsValue: SetErrorType = "invalidRecipients"
 
   def invalidArguments(description: SetErrorDescription, properties: 
Option[Properties] = None): SetError =
     SetError(invalidArgumentValue, description, properties)
@@ -73,6 +74,9 @@ object SetError {
   def tooLarge(description: SetErrorDescription): SetError = {
     SetError(SetError.tooLargeValue, description, None)
   }
+
+  def invalidRecipients(description: SetErrorDescription, properties: 
Option[Properties] = None): SetError =
+    SetError(SetError.invalidRecipientsValue, description, properties)
 }
 
 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/method/EmailSubmissionSetValidation.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetValidation.scala
new file mode 100644
index 0000000000..b65e4139d3
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetValidation.scala
@@ -0,0 +1,38 @@
+/****************************************************************
+ * 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 org.apache.james.jmap.core.SetError
+import org.apache.mailet.Mail
+import reactor.core.scala.publisher.SMono
+
+/**
+ * Extension point allowing to reject an EmailSubmission/set creation before 
the mail gets spooled,
+ * the JMAP counterpart of the SMTP `RcptHook` / `MailHook` mechanism.
+ *
+ * Returning a `SetError` turns the creation into a `notCreated` entry: the 
client is told
+ * synchronously rather than through an asynchronous bounce.
+ *
+ * Implementations are expected to emit `None` when the mail is accepted, and 
`Some(setError)` 
+ * when it is rejected. 
+ */
+trait EmailSubmissionSetValidation {
+  def validate(mail: Mail): SMono[Option[SetError]]
+}
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/ValidRcptEmailSubmissionSetValidation.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/ValidRcptEmailSubmissionSetValidation.scala
new file mode 100644
index 0000000000..a56629978e
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/ValidRcptEmailSubmissionSetValidation.scala
@@ -0,0 +1,77 @@
+/****************************************************************
+ * 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 jakarta.inject.Inject
+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.jmap.method.ValidRcptEmailSubmissionSetValidation.LOGGER
+import org.apache.james.rrt.api.RecipientValidator
+import org.apache.james.util.ReactorUtils
+import org.apache.mailet.Mail
+import org.slf4j.{Logger, LoggerFactory}
+import reactor.core.scala.publisher.{SFlux, SMono}
+
+import scala.jdk.CollectionConverters._
+import scala.util.{Failure, Success, Try}
+
+object ValidRcptEmailSubmissionSetValidation {
+  val LOGGER: Logger = 
LoggerFactory.getLogger(classOf[ValidRcptEmailSubmissionSetValidation])
+}
+
+/**
+ * Rejects submissions carrying recipients that James knows it cannot deliver 
to: recipients of a
+ * local domain that neither have a mailbox nor are covered by a 
RecipientRewriteTable entry.
+ *
+ * The JMAP counterpart of the SMTP `ValidRcptHandler`, sharing its logic 
through
+ * [[org.apache.james.rrt.api.RecipientValidator]]. Recipients of remote 
domains are left alone as
+ * their validity cannot be assessed locally.
+ */
+class ValidRcptEmailSubmissionSetValidation(recipientValidator: 
RecipientValidator,
+                                            policy: RecipientValidator.Policy) 
extends EmailSubmissionSetValidation {
+  @Inject
+  def this(recipientValidator: RecipientValidator) = this(recipientValidator, 
RecipientValidator.Policy.DEFAULT)
+
+  override def validate(mail: Mail): SMono[Option[SetError]] =
+    SFlux.fromIterable(mail.getRecipients.asScala.toSeq)
+      .filterWhen(recipient => isValid(recipient).map(!_), 
ReactorUtils.DEFAULT_CONCURRENCY)
+      .collectSeq()
+      .map {
+        case Seq() => None
+        case invalidRecipients => Some(SetError.invalidRecipients(
+          SetErrorDescription(s"Invalid recipients: 
${invalidRecipients.map(_.asString()).mkString(", ")}"),
+          Some(Properties("envelope.rcptTo"))))
+      }
+
+  private def isValid(recipient: MailAddress): SMono[Boolean] =
+    SMono.fromCallable(() => 
Try(recipientValidator.isValidRecipient(recipient, policy)) match {
+        case Success(valid) => valid
+        // Eg the recipient cannot be turned into a username: such a recipient 
can never be delivered to.
+        case Failure(e: IllegalArgumentException) =>
+          LOGGER.info("Encountered an error upon recipient validation ({}), 
rejecting it", recipient.asString(), e)
+          false
+        // Storage failures are left to bubble up: they translate into a 
serverFail, the JMAP
+        // counterpart of the SMTP deny-soft, rather than into a definitive 
rejection.
+        case Failure(e) => throw e
+      })
+      .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER)
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to