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]
