diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..98caa0cf0d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. +version: 2 +updates: + # Keeps the SHA-pinned third-party actions in .github/workflows up to date: Dependabot + # bumps the commit hash and the trailing "# vX.Y.Z" version comment together. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + # One pull request per week for all action updates instead of one per action. + github-actions: + patterns: ["*"] + labels: + - "ci" + - "dependencies" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10c27101bf..9fe9f84199 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,8 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read jobs: build-maven: runs-on: ${{ matrix.os }} @@ -313,20 +315,20 @@ jobs: echo "release_version=$git_version_last" >> $GITHUB_ENV - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | localhost:5000/${{ github.repository }} tags: | type=raw,value=${{ env.release_version }} - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 with: driver-opts: network=host - name: Build image (default) - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 continue-on-error: true with: context: . @@ -361,7 +363,7 @@ jobs: echo "release_version=$git_version_last" >> $GITHUB_ENV - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | localhost:5000/${{ github.repository }} @@ -369,14 +371,14 @@ jobs: type=raw,value=alpine type=raw,value=${{ env.release_version }}-alpine - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 with: driver-opts: network=host - name: Build image continue-on-error: true - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./Dockerfile-alpine diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bddaecbf0a..2a073ae35a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,11 +22,15 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: false +permissions: + contents: read jobs: deploy-maven: if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event=='push' }} runs-on: 'ubuntu-latest' + permissions: + contents: write # the docs push to the repository wiki uses github.token steps: - name: Print github context env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23c5e681ab..fb27ba8b46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,13 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +permissions: + contents: read jobs: release-maven: runs-on: 'ubuntu-latest' + permissions: + contents: write # release:prepare pushes the release tag, action-gh-release creates the release, docs go to the wiki steps: - name: Print github context env: @@ -75,7 +79,7 @@ jobs: if: ${{ env.MAVEN_USERNAME!='' && env.MAVEN_PASSWORD!='' }} run: mvn --batch-mode -Darguments="-Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }}" -DsignTag=true -DtagNameFormat="${{ github.event.inputs.releaseVersion }}" -DreleaseVersion=${{ github.event.inputs.releaseVersion }} -DdevelopmentVersion=${{ github.event.inputs.developmentVersion }} release:prepare release:perform --file pom.xml - name: Release on GitHub - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: ${{ github.event.inputs.releaseVersion }} tag_name: ${{ github.event.inputs.releaseVersion }} @@ -128,6 +132,9 @@ jobs: release-docker: name: Docker release runs-on: 'ubuntu-latest' + permissions: + contents: read + packages: write # push to ghcr.io with GITHUB_TOKEN needs: - release-maven steps: @@ -138,7 +145,7 @@ jobs: submodules: recursive - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ${{ github.repository }} @@ -147,22 +154,22 @@ jobs: type=raw,value=latest type=raw,value=${{ github.event.inputs.releaseVersion }} - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 continue-on-error: true with: context: . @@ -176,6 +183,9 @@ jobs: release-docker-alpine: name: Docker release runs-on: 'ubuntu-latest' + permissions: + contents: read + packages: write # push to ghcr.io with GITHUB_TOKEN needs: - release-maven steps: @@ -186,7 +196,7 @@ jobs: submodules: recursive - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ${{ github.repository }} @@ -195,23 +205,23 @@ jobs: type=raw,value=alpine type=raw,value=${{ github.event.inputs.releaseVersion }}-alpine - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image continue-on-error: true - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./Dockerfile-alpine diff --git a/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc b/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc index 1abff680ce..2df63146d9 100644 --- a/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc +++ b/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -123,6 +123,13 @@ If `"enable" : false`, you can leave the entries for `"username"` and `"password `starttls`:: If `"enable" : true`, enables the use of the STARTTLS command (if supported by the server) to switch the connection to a TLS-protected connection before issuing any login commands. If the server does not support STARTTLS, the connection continues without the use of TLS. ++ +The SMTP server certificate is validated against the JVM trust store and must be issued for the configured `host`. On Java 17 and later the host name is matched only against the certificate's DNS subject alternative names (or, if it has none, its CN), so `host` must be a DNS name the certificate carries; a relay addressed by IP address is rejected even if its certificate has an IP address subject alternative name. Two optional settings relax that: ++ + +* `trustedHosts`—a list of SMTP host names whose certificate is accepted without validation, for example `"trustedHosts" : [ "smtp.internal.example.com" ]`. Once the list is set, the configured `host` must appear in it exactly as written (the comparison is case-sensitive); any other host is rejected. + +* `trustAll`—when `true`, accepts any server certificate, and takes precedence over `trustedHosts`. This disables protection against man-in-the-middle attacks; use it only in development environments. `from`:: (Optional) Specifies a default `From:` address, that users see when they receive emails from OpenIDM. diff --git a/openidm-external-email/pom.xml b/openidm-external-email/pom.xml index 4ea5893084..8992d23e78 100644 --- a/openidm-external-email/pom.xml +++ b/openidm-external-email/pom.xml @@ -22,7 +22,7 @@ ~ your own identifying information: ~ "Portions Copyrighted [year] [name of copyright owner]" ~ - ~ Portions Copyrighted 2024 3A Systems LLC. + ~ Portions Copyrighted 2024-2026 3A Systems LLC. --> 4.0.0 @@ -42,6 +42,14 @@ org.openidentityplatform.openidm openidm-enhanced-config ${project.version} + + + + com.sun.mail + jakarta.mail + + @@ -113,6 +121,21 @@ org.apache.felix maven-bundle-plugin true + + + + mail;groupId=javax.mail;inline=false + + sun.security.util;resolution:=optional, + javax.security.sasl;resolution:=optional, + * + + + diff --git a/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java b/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java index 31c02d763f..52e15a9bc2 100644 --- a/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java +++ b/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java @@ -20,6 +20,8 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openidm.external.email.impl; @@ -27,7 +29,13 @@ import com.sun.mail.util.MailSSLSocketFactory; import org.forgerock.json.JsonValue; import org.forgerock.json.resource.BadRequestException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; import java.util.Properties; import javax.mail.Message; import javax.mail.MessagingException; @@ -36,12 +44,15 @@ import javax.mail.internet.AddressException; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; +import javax.net.ssl.SSLContext; /** * Email client. */ public class EmailClient { + private static final Logger logger = LoggerFactory.getLogger(EmailClient.class); + private static final String DEFAULT_HOST = "localhost"; private static final String DEFAULT_PORT = "25"; private String username = null; @@ -60,6 +71,13 @@ public class EmailClient { public static final String CONFIG_MAIL_SMTP_AUTH_USERNAME = "username"; public static final String CONFIG_MAIL_SMTP_STARTTLS = "starttls"; public static final String CONFIG_MAIL_SMTP_STARTTLS_ENABLE = "enable"; + /** Opt-in: accept any server certificate over STARTTLS. Never use outside development. */ + public static final String CONFIG_MAIL_SMTP_STARTTLS_TRUST_ALL = "trustAll"; + /** + * Optional list of SMTP hosts whose certificate is accepted without validation. Once set, only + * a {@code host} listed exactly as configured is accepted; any other host is rejected. + */ + public static final String CONFIG_MAIL_SMTP_STARTTLS_TRUSTED_HOSTS = "trustedHosts"; public static final String CONFIG_MAIL_FROM = "from"; public static final String CONFIG_MAIL_DEBUG = "debug"; @@ -83,17 +101,64 @@ public EmailClient(JsonValue config) throws RuntimeException { boolean startTLS = starttlsConfig.get(CONFIG_MAIL_SMTP_STARTTLS_ENABLE).defaultTo(false).asBoolean(); if (startTLS) { props.put("mail.smtp.starttls.enable", String.valueOf(startTLS)); - // temporary hack to avoid cert check - try { - MailSSLSocketFactory sf = new MailSSLSocketFactory(); - sf.setTrustAllHosts(true); - props.put("mail.smtp.ssl.socketFactory", sf); - } catch (Exception e) { - } + // without this JavaMail 1.4.7 enables only TLSv1 for STARTTLS, which current JDKs disable + props.put("mail.smtp.ssl.protocols", defaultTlsProtocols()); + configureStartTlsTrust(starttlsConfig); } fromAddr = config.get(CONFIG_MAIL_FROM).asString(); - session = Session.getInstance(props); + // JavaMail looks up its providers and resources through the context class loader first; + // point it at this bundle so the embedded javax.mail is used, not another copy + ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(EmailClient.class.getClassLoader()); + session = Session.getInstance(props); + } finally { + Thread.currentThread().setContextClassLoader(originalContextClassLoader); + } + } + + /** The TLS protocols the JVM enables by default, space-separated as JavaMail expects them. */ + private static String defaultTlsProtocols() { + try { + return String.join(" ", SSLContext.getDefault().getDefaultSSLParameters().getProtocols()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Unable to determine the default TLS protocols", e); + } + } + + /** + * By default the server certificate is validated against the JVM trust store and must be + * issued for the configured host. A custom socket factory is installed only when the + * configuration explicitly relaxes that, either for a list of {@code trustedHosts} or, for + * development only, for all hosts. + */ + private void configureStartTlsTrust(JsonValue starttlsConfig) { + boolean trustAll = starttlsConfig.get(CONFIG_MAIL_SMTP_STARTTLS_TRUST_ALL).defaultTo(false).asBoolean(); + List trustedHosts = starttlsConfig.get(CONFIG_MAIL_SMTP_STARTTLS_TRUSTED_HOSTS) + .defaultTo(Collections.emptyList()).asList(String.class); + if (!trustAll && trustedHosts.isEmpty()) { + // validate the chain (JSSE default) and that the certificate was issued for the host + props.put("mail.smtp.ssl.checkserveridentity", "true"); + return; + } + String host = props.getProperty("mail.smtp.host"); + if (!trustAll && !trustedHosts.contains(host)) { + // JavaMail matches the host against the list exactly and rejects any other host + logger.warn("starttls.trustedHosts {} does not contain the SMTP host {}", trustedHosts, host); + } + try { + MailSSLSocketFactory sf = new MailSSLSocketFactory(); + if (trustAll) { + logger.warn("SMTP STARTTLS certificate validation is disabled (starttls.trustAll=true)"); + sf.setTrustAllHosts(trustAll); + } else { + sf.setTrustedHosts(trustedHosts.toArray(new String[0])); + } + props.put("mail.smtp.ssl.socketFactory", sf); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to configure the SMTP STARTTLS socket factory", e); + } } /** @@ -160,7 +225,10 @@ public void send(JsonValue params) throws BadRequestException { throw new BadRequestException("Bad Bcc: email address"); } + // the transport and the content handlers are loaded through the context class loader (see constructor) + ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader(); try { + Thread.currentThread().setContextClassLoader(EmailClient.class.getClassLoader()); Message message = new MimeMessage(session); message.setFrom(from); message.setRecipients(Message.RecipientType.TO, to); @@ -204,6 +272,8 @@ public void send(JsonValue params) throws BadRequestException { } catch (MessagingException e) { throw new BadRequestException(e); + } finally { + Thread.currentThread().setContextClassLoader(originalContextClassLoader); } } diff --git a/openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java b/openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java new file mode 100644 index 0000000000..628ff368d2 --- /dev/null +++ b/openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java @@ -0,0 +1,125 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openidm.external.email.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.json.JsonValue.array; +import static org.forgerock.json.JsonValue.field; +import static org.forgerock.json.JsonValue.json; +import static org.forgerock.json.JsonValue.object; + +import java.lang.reflect.Field; +import java.util.Properties; + +import javax.mail.Session; +import javax.net.ssl.SSLContext; + +import com.sun.mail.util.MailSSLSocketFactory; +import org.forgerock.json.JsonValue; +import org.testng.annotations.Test; + +/** + * Tests for the STARTTLS trust settings of {@link EmailClient}. + */ +public class EmailClientTest { + + private static final String SOCKET_FACTORY = "mail.smtp.ssl.socketFactory"; + private static final String CHECK_SERVER_IDENTITY = "mail.smtp.ssl.checkserveridentity"; + + @Test + public void startTlsValidatesTheServerCertificateByDefault() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true)))))); + + assertThat(props.get("mail.smtp.starttls.enable")).isEqualTo("true"); + assertThat(props.get(SOCKET_FACTORY)).as("no custom trust: JSSE validation applies").isNull(); + assertThat(props.get(CHECK_SERVER_IDENTITY)).isEqualTo("true"); + } + + @Test + public void startTlsTrustAllIsOptIn() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true), field("trustAll", true)))))); + + MailSSLSocketFactory sf = (MailSSLSocketFactory) props.get(SOCKET_FACTORY); + assertThat(sf).isNotNull(); + assertThat(sf.isTrustAllHosts()).isTrue(); + } + + @Test + public void startTlsTrustedHostsAreLimitedToTheConfiguredList() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true), + field("trustedHosts", array("smtp.example.com", "mail.internal"))))))); + + MailSSLSocketFactory sf = (MailSSLSocketFactory) props.get(SOCKET_FACTORY); + assertThat(sf).isNotNull(); + assertThat(sf.isTrustAllHosts()).isFalse(); + assertThat(sf.getTrustedHosts()).containsExactly("smtp.example.com", "mail.internal"); + assertThat(props.get(CHECK_SERVER_IDENTITY)).isNull(); + } + + @Test + public void startTlsTrustAllWinsOverTrustedHosts() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true), field("trustAll", true), + field("trustedHosts", array("mail.internal"))))))); + + MailSSLSocketFactory sf = (MailSSLSocketFactory) props.get(SOCKET_FACTORY); + assertThat(sf.isTrustAllHosts()).isTrue(); + } + + @Test + public void startTlsUsesTheJvmDefaultProtocols() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true)))))); + + // JavaMail 1.4.7 falls back to TLSv1 alone, which current JDKs disable + assertThat(props.getProperty("mail.smtp.ssl.protocols").split(" ")) + .containsExactly(SSLContext.getDefault().getDefaultSSLParameters().getProtocols()); + } + + @Test + public void socketFactoryComesFromTheJavaMailInUse() { + // JavaMail checks trustedHosts only for its own MailSSLSocketFactory class; a copy from + // another mail jar (jakarta.mail) is not recognised and then trusts every host + assertThat(MailSSLSocketFactory.class.getProtectionDomain().getCodeSource().getLocation()) + .isEqualTo(Session.class.getProtectionDomain().getCodeSource().getLocation()); + } + + @Test + public void trustSettingsApplyOnlyWithStartTls() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", false), field("trustAll", true)))))); + + assertThat(props.get("mail.smtp.starttls.enable")).isNull(); + assertThat(props.get(SOCKET_FACTORY)).isNull(); + assertThat(props.get(CHECK_SERVER_IDENTITY)).isNull(); + } + + private static Properties sessionProperties(JsonValue config) throws Exception { + EmailClient client = new EmailClient(config); + Field session = EmailClient.class.getDeclaredField("session"); + session.setAccessible(true); + return ((Session) session.get(client)).getProperties(); + } +} diff --git a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/settings/EmailConfigView.js b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/settings/EmailConfigView.js index e7040710f6..29061ce6fb 100644 --- a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/settings/EmailConfigView.js +++ b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/settings/EmailConfigView.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ define([ @@ -133,6 +134,10 @@ define([ e.preventDefault(); var formData = form2js("emailConfigForm",".", true); + if (_.has(formData, "starttls")) { + // keep the STARTTLS keys the form does not edit (trustedHosts, trustAll) + formData.starttls = _.extend({}, this.data.config.starttls, formData.starttls); + } _.extend(this.data.config, formData); if (!_.has(formData, "starttls") || !_.has(formData.starttls, "enable")) { diff --git a/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/settings/EmailConfigViewTest.js b/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/settings/EmailConfigViewTest.js index 19d92721b4..cf78712bd5 100644 --- a/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/settings/EmailConfigViewTest.js +++ b/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/settings/EmailConfigViewTest.js @@ -1,5 +1,53 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Portions Copyright 2026 3A Systems, LLC. + */ + define([ - "org/forgerock/openidm/ui/admin/settings/EmailConfigView" -], function (EmailConfigView) { + "jquery", + "sinon", + "org/forgerock/openidm/ui/admin/settings/EmailConfigView", + "org/forgerock/openidm/ui/common/delegates/ConfigDelegate" +], function ($, sinon, EmailConfigView, ConfigDelegate) { QUnit.module('EmailConfigView Tests'); -}); \ No newline at end of file + + QUnit.test("save keeps the STARTTLS keys the form does not edit", function (assert) { + var saved, + stub = sinon.stub(ConfigDelegate, "updateEntity", function (id, config) { + saved = config; + return $.Deferred(); + }); + + $("#qunit-fixture").html('' + + '
' + + '' + + '' + + '
'); + EmailConfigView.$el = $("#qunit-fixture"); + EmailConfigView.model = { externalEmailExists: true }; + EmailConfigView.data = { + config: { + host: "smtp.example.com", + starttls: { enable: true, trustedHosts: ["smtp.internal"], trustAll: false } + } + }; + + EmailConfigView.save({ preventDefault: $.noop }); + stub.restore(); + + assert.ok(saved.starttls.enable, "the form's STARTTLS flag is saved"); + assert.deepEqual(saved.starttls.trustedHosts, ["smtp.internal"], "trustedHosts is kept"); + assert.strictEqual(saved.starttls.trustAll, false, "trustAll is kept"); + }); +});