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 7f91ba459bbb411670ecc0d372d97612c10c2843
Author: Benoit TELLIER <[email protected]>
AuthorDate: Thu Sep 10 13:37:11 2026 +0200

    JAMES-4228 Plug EmailSubmissionSetValidation into EmailSubmissionSetMethod
---
 docs/modules/servers/partials/configure/jmap.adoc  | 18 ++++++++++++++++
 .../sample-configuration/jmap.properties           |  6 ++++++
 .../sample-configuration/jmap.properties           |  6 ++++++
 .../sample-configuration/jmap.properties           |  6 ++++++
 .../james/jmap/rfc8621/RFC8621MethodsModule.java   | 14 ++++++++++++
 .../james/jmap/core/JmapRfc8621Configuration.scala |  6 ++++++
 .../jmap/method/EmailSubmissionSetMethod.scala     | 25 ++++++++++++++++++++++
 7 files changed, 81 insertions(+)

diff --git a/docs/modules/servers/partials/configure/jmap.adoc 
b/docs/modules/servers/partials/configure/jmap.adoc
index ac8f6708c7..17b605d755 100644
--- a/docs/modules/servers/partials/configure/jmap.adoc
+++ b/docs/modules/servers/partials/configure/jmap.adoc
@@ -121,6 +121,24 @@ systems with filters already defined would result in those 
filters to be not rea
 | delay.sends.enabled
 | Optional boolean. Defaults to false. Whether to support or not the delay 
send with JMAP protocol.
 
+| send.validate.rcpt
+| Optional boolean. Defaults to false. Whether `EmailSubmission/set` rejects 
recipients James knows it cannot
+deliver to, the JMAP counterpart of the SMTP `ValidRcptHandler`. Only 
recipients of local domains are checked:
+they need either a local mailbox or a RecipientRewriteTable entry. When 
enabled the client is told synchronously
+with an `invalidRecipients` SetError rather than through an asynchronous 
bounce. Note that, the JMAP data model
+having no room for a partial rejection, a single invalid recipient fails the 
whole submission. Also note that
+this turns `EmailSubmission/set` into an oracle telling which local addresses 
exist.
+
+| send.validate.rcpt.enableRecipientRewriteTable
+| Optional boolean. Defaults to true. Whether recipients without a local 
mailbox can be validated through the
+RecipientRewriteTable. Only applies when `send.validate.rcpt` is enabled.
+
+| send.validate.rcpt.recipientRewriteTableCheck
+| Optional. Defaults to `mappingExists`. How strict the RecipientRewriteTable 
validation is: `mappingExists`
+accepts any recipient having a mapping, `anyMappingValid` requires at least 
one target to have a local mailbox,
+`allMappingsValid` requires all of them to. Only applies when 
`send.validate.rcpt` is enabled. Mirrors the
+`smtpserver.xml` `ValidRcptHandler` option of the same name.
+
 | disabled.capabilities
 | Optional, defaults to empty. Coma separated list of JMAP capabilities to 
reject.
 This allows to prevent users from using some specific JMAP extensions.
diff --git a/server/apps/distributed-app/sample-configuration/jmap.properties 
b/server/apps/distributed-app/sample-configuration/jmap.properties
index b6d1ceec5b..b6af586348 100644
--- a/server/apps/distributed-app/sample-configuration/jmap.properties
+++ b/server/apps/distributed-app/sample-configuration/jmap.properties
@@ -9,6 +9,12 @@ tls.secret=james72laBalle
 # only not work for RabbitMQ mail queue
 #delay.sends.enabled=true
 
+# Reject recipients James knows it cannot deliver to upon EmailSubmission/set, 
rather than bouncing
+# them asynchronously. Only recipients of local domains are checked.
+#send.validate.rcpt=true
+#send.validate.rcpt.enableRecipientRewriteTable=true
+#send.validate.rcpt.recipientRewriteTableCheck=mappingExists
+
 # Alternatively TLS keys can be supplied via PEM files
 # tls.privateKey=file://conf/private.nopass.key
 # tls.certificates=file://conf/certs.self-signed.csr
diff --git a/server/apps/memory-app/sample-configuration/jmap.properties 
b/server/apps/memory-app/sample-configuration/jmap.properties
index 7b255ed1bf..b6f198ac0b 100644
--- a/server/apps/memory-app/sample-configuration/jmap.properties
+++ b/server/apps/memory-app/sample-configuration/jmap.properties
@@ -7,6 +7,12 @@ tls.keystoreURL=file://conf/keystore
 tls.secret=james72laBalle
 delay.sends.enabled=true
 
+# Reject recipients James knows it cannot deliver to upon EmailSubmission/set, 
rather than bouncing
+# them asynchronously. Only recipients of local domains are checked.
+#send.validate.rcpt=true
+#send.validate.rcpt.enableRecipientRewriteTable=true
+#send.validate.rcpt.recipientRewriteTableCheck=mappingExists
+
 # Alternatively TLS keys can be supplied via PEM files
 # tls.privateKey=file://conf/private.nopass.key
 # tls.certificates=file://conf/certs.self-signed.csr
diff --git a/server/apps/postgres-app/sample-configuration/jmap.properties 
b/server/apps/postgres-app/sample-configuration/jmap.properties
index b6d1ceec5b..b6af586348 100644
--- a/server/apps/postgres-app/sample-configuration/jmap.properties
+++ b/server/apps/postgres-app/sample-configuration/jmap.properties
@@ -9,6 +9,12 @@ tls.secret=james72laBalle
 # only not work for RabbitMQ mail queue
 #delay.sends.enabled=true
 
+# Reject recipients James knows it cannot deliver to upon EmailSubmission/set, 
rather than bouncing
+# them asynchronously. Only recipients of local domains are checked.
+#send.validate.rcpt=true
+#send.validate.rcpt.enableRecipientRewriteTable=true
+#send.validate.rcpt.recipientRewriteTableCheck=mappingExists
+
 # Alternatively TLS keys can be supplied via PEM files
 # tls.privateKey=file://conf/private.nopass.key
 # tls.certificates=file://conf/certs.self-signed.csr
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 02a498d25f..a7243b01af 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
@@ -67,6 +67,7 @@ import org.apache.james.jmap.method.EmailQueryOptimizer;
 import org.apache.james.jmap.method.EmailQueryViewOptimizer;
 import org.apache.james.jmap.method.EmailSetMethod;
 import org.apache.james.jmap.method.EmailSubmissionSetMethod;
+import org.apache.james.jmap.method.EmailSubmissionSetValidation;
 import org.apache.james.jmap.method.IdentityChangesMethod;
 import org.apache.james.jmap.method.IdentityGetMethod;
 import org.apache.james.jmap.method.IdentitySetMethod;
@@ -89,6 +90,7 @@ import org.apache.james.jmap.method.ThreadChangesMethod;
 import org.apache.james.jmap.method.ThreadGetMethod;
 import org.apache.james.jmap.method.VacationResponseGetMethod;
 import org.apache.james.jmap.method.VacationResponseSetMethod;
+import org.apache.james.jmap.method.ValidRcptEmailSubmissionSetValidation;
 import org.apache.james.jmap.method.ZoneIdProvider;
 import org.apache.james.jmap.pushsubscription.DefaultWebPushClient;
 import org.apache.james.jmap.pushsubscription.PushClientConfiguration;
@@ -100,6 +102,7 @@ import org.apache.james.jmap.routes.SessionRoutes;
 import org.apache.james.jmap.routes.UploadRoutes;
 import org.apache.james.jmap.routes.WebSocketRoutes;
 import org.apache.james.metrics.api.MetricFactory;
+import org.apache.james.rrt.api.RecipientValidator;
 import org.apache.james.utils.ClassName;
 import org.apache.james.utils.GuiceLoader;
 import org.apache.james.utils.InitializationOperation;
@@ -114,6 +117,7 @@ import com.github.fge.lambdas.Throwing;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provider;
 import com.google.inject.Provides;
 import com.google.inject.Scopes;
 import com.google.inject.Singleton;
@@ -241,6 +245,16 @@ public class RFC8621MethodsModule extends AbstractModule {
             .collect(ImmutableSet.toImmutableSet());
     }
 
+    @Provides
+    @Singleton
+    public Set<EmailSubmissionSetValidation> 
provideEmailSubmissionSetValidations(JmapRfc8621Configuration configuration,
+                                                                               
  Provider<RecipientValidator> recipientValidator) {
+        if (!configuration.validateRecipientsOnSend()) {
+            return ImmutableSet.of();
+        }
+        return ImmutableSet.of(new 
ValidRcptEmailSubmissionSetValidation(recipientValidator.get(), 
configuration.recipientValidationPolicy()));
+    }
+
     @Provides
     @Singleton
     PushClientConfiguration 
providePushClientConfiguration(JmapRfc8621Configuration configuration) {
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
index 85d02bad67..0931640308 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
@@ -29,6 +29,7 @@ import org.apache.commons.configuration2.Configuration
 import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
 import 
org.apache.james.jmap.core.JmapRfc8621Configuration.{JMAP_EMAIL_GET_FULL_MAX_SIZE_DEFAULT,
 JMAP_MAX_OBJECT_IN_GET, JMAP_MAX_OBJECT_IN_SET, 
JMAP_UPLOAD_QUOTA_LIMIT_DEFAULT, MAX_SIZE_ATTACHMENTS_PER_MAIL_DEFAULT, 
UPLOAD_LIMIT_DEFAULT}
 import org.apache.james.jmap.pushsubscription.PushClientConfiguration
+import org.apache.james.rrt.api.RecipientValidator
 import org.apache.james.util.{DurationParser, Size}
 
 import scala.concurrent.duration.Duration
@@ -54,6 +55,7 @@ object JmapConfigProperties {
   val JMAP_EMAIL_GET_FULL_MAX_SIZE_PROPERTY: String = "email.get.full.max.size"
   val JMAP_GET_MAX_SIZE_PROPERTY: String = "get.max.size"
   val JMAP_SET_MAX_SIZE_PROPERTY: String = "set.max.size"
+  val SEND_VALIDATE_RCPT_PROPERTY: String = "send.validate.rcpt"
 }
 
 object JmapRfc8621Configuration {
@@ -105,6 +107,8 @@ object JmapRfc8621Configuration {
       maxObjectsInSet = 
Option(configuration.getLong(JMAP_SET_MAX_SIZE_PROPERTY, null))
         .map(value => MaxObjectsInSet(UnsignedInt.liftOrThrow(value)))
         .getOrElse(JMAP_MAX_OBJECT_IN_SET),
+      validateRecipientsOnSend = 
configuration.getBoolean(SEND_VALIDATE_RCPT_PROPERTY, false),
+      recipientValidationPolicy = 
RecipientValidator.Policy.from(configuration.subset(SEND_VALIDATE_RCPT_PROPERTY)),
       webPushEnabled = configuration.getBoolean(WEB_PUSH_ENABLED_PROPERTY, 
true),
       maxTimeoutSeconds = 
Optional.ofNullable(configuration.getInteger(WEB_PUSH_MAX_TIMEOUT_SECONDS_PROPERTY,
 null)).map(Integer2int).toScala,
       maxConnections = 
Optional.ofNullable(configuration.getInteger(WEB_PUSH_MAX_CONNECTIONS_PROPERTY, 
null)).map(Integer2int).toScala,
@@ -129,6 +133,8 @@ case class JmapRfc8621Configuration(urlPrefixString: String,
                                     jmapEmailGetFullMaxSize: 
JmapEmailGetFullMaxSize = JMAP_EMAIL_GET_FULL_MAX_SIZE_DEFAULT,
                                     maxObjectsInGet: MaxObjectsInGet = 
JMAP_MAX_OBJECT_IN_GET,
                                     maxObjectsInSet: MaxObjectsInSet = 
JMAP_MAX_OBJECT_IN_SET,
+                                    validateRecipientsOnSend: Boolean = false,
+                                    recipientValidationPolicy: 
RecipientValidator.Policy = RecipientValidator.Policy.DEFAULT,
                                     webPushEnabled: Boolean = true,
                                     maxTimeoutSeconds: Option[Int] = None,
                                     maxConnections: Option[Int] = None,
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala
index adf5866e19..716e90a334 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailSubmissionSetMethod.scala
@@ -81,6 +81,9 @@ object EmailSubmissionSetMethod {
                              messageId: MessageId) extends CreationResult
   case class CreationFailure(emailSubmissionCreationId: 
EmailSubmissionCreationId, exception: Throwable) extends CreationResult {
     def asSetError: SetError = exception match {
+      case e: EmailSubmissionSetValidationException =>
+        LOGGER.info("EmailSubmission/set rejected by a validation: {}", 
e.setError.description.description)
+        e.setError
       case e: EmailSubmissionCreationParseException =>
         LOGGER.info("Failed to parse EMailSubmission/set create", e)
         e.setError
@@ -157,6 +160,7 @@ object EmailSubmissionSetMethod {
 }
 
 case class EmailSubmissionCreationParseException(setError: SetError) extends 
Exception
+case class EmailSubmissionSetValidationException(setError: SetError) extends 
Exception
 case class NoRecipientException() extends Exception
 case class ForbiddenFromException(from: String) extends Exception
 case class ForbiddenMailFromException(from: List[String]) extends Exception
@@ -177,9 +181,12 @@ class EmailSubmissionSetMethod @Inject()(serializer: 
EmailSubmissionSetSerialize
                                          canSendFrom: CanSendFrom,
                                          emailSetMethod: EmailSetMethod,
                                          clock: Clock,
+                                         javaValidations: 
java.util.Set[EmailSubmissionSetValidation],
                                          val metricFactory: MetricFactory,
                                          val sessionSupplier: SessionSupplier,
                                          val sessionTranslator: 
SessionTranslator) extends MethodRequiringAccountId[EmailSubmissionSetRequest] 
with Startable {
+  private val validations: Seq[EmailSubmissionSetValidation] = 
javaValidations.asScala.toSeq
+
   override val methodName: MethodName = MethodName("EmailSubmission/set")
   override val requiredCapabilities: Set[CapabilityIdentifier] = 
Set(JMAP_CORE, EMAIL_SUBMISSION)
   var queue: MailQueue = _
@@ -292,6 +299,7 @@ class EmailSubmissionSetMethod @Inject()(serializer: 
EmailSubmissionSetSerialize
         mailImpl.setMessageNoCopy(message)
         mailImpl
       }
+      _ <- applyValidations(mail)
       _ <- enqueue(mail, delay, mailboxSession)
         .`then`(SMono.just(submissionId))
       sendAt = UTCDate(ZonedDateTime.now(clock).plus(delay))
@@ -299,6 +307,23 @@ class EmailSubmissionSetMethod @Inject()(serializer: 
EmailSubmissionSetSerialize
       EmailSubmissionCreationResponse(submissionId, sendAt) -> request.emailId
     }
 
+  /**
+   * Runs the pluggable validations against the mail about to be spooled. The 
first rejection wins,
+   * subsequent validations are not evaluated. As the mail holds the message 
content, it needs
+   * disposing whenever it does not reach the queue.
+   */
+  private def applyValidations(mail: Mail): SMono[Mail] =
+    SFlux.fromIterable(validations)
+      .concatMap(_.validate(mail))
+      .filter(_.isDefined)
+      .map(_.get)
+      .next()
+      .flatMap[Mail](setError => 
SMono.error(EmailSubmissionSetValidationException(setError)))
+      .switchIfEmpty(SMono.just(mail))
+      .onErrorResume(e => SMono.fromCallable(() => LifecycleUtil.dispose(mail))
+        .subscribeOn(Schedulers.boundedElastic())
+        .`then`(SMono.error(e)))
+
   private def enqueue(mail: Mail, delay: Duration, mailboxSession: 
MailboxSession): SMono[Unit] =
     (delay match {
       case d if d.isNegative || d.isZero => SMono(queue.enqueueReactive(mail))


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

Reply via email to