aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAntoine Vigneau <antoine.vigneau@sonarsource.com>2024-08-06 13:45:14 +0200
committersonartech <sonartech@sonarsource.com>2024-08-16 20:02:58 +0000
commit461a47d22b55a5450062883a6c1bd22e0a4ec3dd (patch)
tree7107a6598c46d4f3899f9659a14232f4d4b552b8
parent808cba5b51d54a61ef2a01e6e78e3874ef53eea5 (diff)
downloadsonarqube-461a47d22b55a5450062883a6c1bd22e0a4ec3dd.tar.gz
sonarqube-461a47d22b55a5450062883a6c1bd22e0a4ec3dd.zip
SONAR-22516 API v2 endpoints for managing the email configuration
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/email/EmailSmtpConfiguration.java17
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/notification/email/telemetry/package-info.java23
-rw-r--r--server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationBuilder.java119
-rw-r--r--server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationServiceIT.java477
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfiguration.java52
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationAuthMethod.java24
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationSecurityProtocol.java24
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationService.java284
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/UpdateEmailConfigurationRequest.java139
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java31
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/DefaultEmailConfigurationController.java164
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/EmailConfigurationController.java97
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationCreateRestRequest.java84
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationUpdateRestRequest.java162
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationAuthMethod.java24
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationResource.java74
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationSecurityProtocol.java24
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/EmailConfigurationSearchRestResponse.java27
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmpty.java44
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidator.java31
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java8
-rw-r--r--server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/email/config/DefaultEmailConfigurationControllerTest.java597
-rw-r--r--server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidatorTest.java49
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/email/ws/EmailsWsModule.java2
-rw-r--r--server/sonar-webserver-webapi/src/test/java/org/sonar/server/email/ws/EmailsWsModuleTest.java2
-rw-r--r--sonar-ws/src/main/java/org/sonarqube/ws/client/emails/EmailConfigurationService.java91
-rw-r--r--sonar-ws/src/main/java/org/sonarqube/ws/client/emails/WsEmailConfiguration.java129
32 files changed, 2891 insertions, 23 deletions
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/email/EmailSmtpConfiguration.java b/server/sonar-server-common/src/main/java/org/sonar/server/email/EmailSmtpConfiguration.java
index b7873fab943..f8ab524b7e2 100644
--- a/server/sonar-server-common/src/main/java/org/sonar/server/email/EmailSmtpConfiguration.java
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/email/EmailSmtpConfiguration.java
@@ -34,8 +34,6 @@ public class EmailSmtpConfiguration {
public static final String EMAIL_CONFIG_SMTP_PORT_DEFAULT = "25";
public static final String EMAIL_CONFIG_SMTP_SECURE_CONNECTION = "email.smtp_secure_connection.secured";
public static final String EMAIL_CONFIG_SMTP_SECURE_CONNECTION_DEFAULT = "";
- public static final String EMAIL_CONFIG_SMTP_AUTH_METHOD= "email.smtp.auth.method";
- public static final String EMAIL_CONFIG_SMTP_AUTH_METHOD_DEFAULT = "BASIC";
// Email content
public static final String EMAIL_CONFIG_FROM = "email.from";
public static final String EMAIL_CONFIG_FROM_DEFAULT = "noreply@nowhere";
@@ -43,19 +41,24 @@ public class EmailSmtpConfiguration {
public static final String EMAIL_CONFIG_FROM_NAME_DEFAULT = "SonarQube";
public static final String EMAIL_CONFIG_PREFIX = "email.prefix";
public static final String EMAIL_CONFIG_PREFIX_DEFAULT = "[SONARQUBE]";
+ // Auth selection
+ public static final String EMAIL_CONFIG_SMTP_AUTH_METHOD= "email.smtp.auth.method";
+ public static final String EMAIL_CONFIG_SMTP_AUTH_METHOD_DEFAULT = "BASIC";
// Basic Auth
public static final String EMAIL_CONFIG_SMTP_USERNAME = "email.smtp_username.secured";
public static final String EMAIL_CONFIG_SMTP_USERNAME_DEFAULT = "";
public static final String EMAIL_CONFIG_SMTP_PASSWORD = "email.smtp_password.secured";
public static final String EMAIL_CONFIG_SMTP_PASSWORD_DEFAULT = "";
- // Modern auth
+ // OAuth
public static final String EMAIL_CONFIG_SMTP_OAUTH_HOST = "email.smtp.oauth.host";
public static final String EMAIL_CONFIG_SMTP_OAUTH_HOST_DEFAULT = "https://login.microsoftonline.com";
- public static final String EMAIL_CONFIG_SMTP_OAUTH_TENANT = "email.smtp.oauth.tenant";
public static final String EMAIL_CONFIG_SMTP_OAUTH_CLIENTID = "email.smtp.oauth.clientId";
public static final String EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET = "email.smtp.oauth.clientSecret";
+ public static final String EMAIL_CONFIG_SMTP_OAUTH_TENANT = "email.smtp.oauth.tenant";
public static final String EMAIL_CONFIG_SMTP_OAUTH_SCOPE = "email.smtp.oauth.scope";
- public static final String EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT = "client_credentials";
+ public static final String EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT = "https://outlook.office365.com/.default";
+ public static final String EMAIL_CONFIG_SMTP_OAUTH_GRANT = "email.smtp.oauth.grant";
+ public static final String EMAIL_CONFIG_SMTP_OAUTH_GRANT_DEFAULT = "client_credentials";
private final DbClient dbClient;
@@ -119,6 +122,10 @@ public class EmailSmtpConfiguration {
return get(EMAIL_CONFIG_SMTP_OAUTH_SCOPE, EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT);
}
+ public String getOAuthGrant() {
+ return get(EMAIL_CONFIG_SMTP_OAUTH_GRANT, EMAIL_CONFIG_SMTP_OAUTH_GRANT_DEFAULT);
+ }
+
private String get(String key, String defaultValue) {
try (DbSession dbSession = dbClient.openSession(false)) {
return dbClient.internalPropertiesDao().selectByKey(dbSession, key).orElse(defaultValue);
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/telemetry/package-info.java b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/telemetry/package-info.java
new file mode 100644
index 00000000000..0722ecbfca8
--- /dev/null
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/telemetry/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.notification.email.telemetry;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationBuilder.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationBuilder.java
new file mode 100644
index 00000000000..ae619646223
--- /dev/null
+++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationBuilder.java
@@ -0,0 +1,119 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+public final class EmailConfigurationBuilder {
+ private String id;
+ private String host;
+ private String port;
+ private EmailConfigurationSecurityProtocol securityProtocol;
+ private String fromAddress;
+ private String fromName;
+ private String subjectPrefix;
+ private EmailConfigurationAuthMethod authMethod;
+ private String username;
+ private String basicPassword;
+ private String oauthAuthenticationHost;
+ private String oauthClientId;
+ private String oauthClientSecret;
+ private String oauthTenant;
+
+ private EmailConfigurationBuilder() {
+ }
+
+ public static EmailConfigurationBuilder builder() {
+ return new EmailConfigurationBuilder();
+ }
+
+ public EmailConfigurationBuilder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public EmailConfigurationBuilder host(String host) {
+ this.host = host;
+ return this;
+ }
+
+ public EmailConfigurationBuilder port(String port) {
+ this.port = port;
+ return this;
+ }
+
+ public EmailConfigurationBuilder securityProtocol(EmailConfigurationSecurityProtocol securityProtocol) {
+ this.securityProtocol = securityProtocol;
+ return this;
+ }
+
+ public EmailConfigurationBuilder fromAddress(String fromAddress) {
+ this.fromAddress = fromAddress;
+ return this;
+ }
+
+ public EmailConfigurationBuilder fromName(String fromName) {
+ this.fromName = fromName;
+ return this;
+ }
+
+ public EmailConfigurationBuilder subjectPrefix(String subjectPrefix) {
+ this.subjectPrefix = subjectPrefix;
+ return this;
+ }
+
+ public EmailConfigurationBuilder authMethod(EmailConfigurationAuthMethod authMethod) {
+ this.authMethod = authMethod;
+ return this;
+ }
+
+ public EmailConfigurationBuilder username(String username) {
+ this.username = username;
+ return this;
+ }
+
+ public EmailConfigurationBuilder basicPassword(String basicPassword) {
+ this.basicPassword = basicPassword;
+ return this;
+ }
+
+ public EmailConfigurationBuilder oauthAuthenticationHost(String oauthAuthenticationHost) {
+ this.oauthAuthenticationHost = oauthAuthenticationHost;
+ return this;
+ }
+
+ public EmailConfigurationBuilder oauthClientId(String oauthClientId) {
+ this.oauthClientId = oauthClientId;
+ return this;
+ }
+
+ public EmailConfigurationBuilder oauthClientSecret(String oauthClientSecret) {
+ this.oauthClientSecret = oauthClientSecret;
+ return this;
+ }
+
+ public EmailConfigurationBuilder oauthTenant(String oauthTenant) {
+ this.oauthTenant = oauthTenant;
+ return this;
+ }
+
+ public EmailConfiguration build() {
+ return new EmailConfiguration(id, host, port, securityProtocol, fromAddress, fromName, subjectPrefix, authMethod, username, basicPassword, oauthAuthenticationHost,
+ oauthClientId, oauthClientSecret, oauthTenant);
+ }
+}
diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationServiceIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationServiceIT.java
new file mode 100644
index 00000000000..2ebb26e580c
--- /dev/null
+++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationServiceIT.java
@@ -0,0 +1,477 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.platform.commons.util.StringUtils;
+import org.sonar.db.DbSession;
+import org.sonar.db.DbTester;
+import org.sonar.server.exceptions.BadRequestException;
+import org.sonar.server.exceptions.NotFoundException;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.sonar.server.common.NonNullUpdatedValue.undefined;
+import static org.sonar.server.common.NonNullUpdatedValue.withValueOrThrow;
+import static org.sonar.server.common.email.config.EmailConfigurationService.UNIQUE_EMAIL_CONFIGURATION_ID;
+import static org.sonar.server.common.email.config.UpdateEmailConfigurationRequest.builder;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_HOST;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_CLIENTID;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_HOST;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_TENANT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_PASSWORD;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_USERNAME;
+
+class EmailConfigurationServiceIT {
+
+ private static final String OAUTH_URLS_ERROR_MESSAGE = "For security reasons, OAuth urls can't be updated without providing the client secret.";
+ private static final String BASIC_URLS_ERROR_MESSAGE = "For security reasons, the host can't be updated without providing the password.";
+
+ private static final EmailConfigurationBuilder EMAIL_BASIC_CONFIG_BUILDER = EmailConfigurationBuilder.builder()
+ .id(UNIQUE_EMAIL_CONFIGURATION_ID)
+ .host("host")
+ .port("port")
+ .securityProtocol(EmailConfigurationSecurityProtocol.NONE)
+ .fromAddress("fromAddress")
+ .fromName("fromName")
+ .subjectPrefix("subjectPrefix")
+ .authMethod(EmailConfigurationAuthMethod.BASIC)
+ .username("username")
+ .basicPassword("basicPassword")
+ .oauthAuthenticationHost("oauthAuthenticationHost")
+ .oauthClientId("oauthClientId")
+ .oauthClientSecret("oauthClientSecret")
+ .oauthTenant("oauthTenant");
+
+ private static final EmailConfigurationBuilder EMAIL_OAUTH_CONFIG_BUILDER = EmailConfigurationBuilder.builder()
+ .id(UNIQUE_EMAIL_CONFIGURATION_ID)
+ .host("hostOAuth")
+ .port("portOAuth")
+ .securityProtocol(EmailConfigurationSecurityProtocol.SSLTLS)
+ .fromAddress("fromAddressOAuth")
+ .fromName("fromNameOAuth")
+ .subjectPrefix("subjectPrefixOAuth")
+ .authMethod(EmailConfigurationAuthMethod.OAUTH)
+ .username("usernameOAuth")
+ .basicPassword("basicPasswordOAuth")
+ .oauthAuthenticationHost("oauthAuthenticationHostOAuth")
+ .oauthClientId("oauthClientIdOAuth")
+ .oauthClientSecret("oauthClientSecretOAuth")
+ .oauthTenant("oauthTenantOAuth");
+
+ @RegisterExtension
+ public DbTester dbTester = DbTester.create();
+
+ private EmailConfigurationService underTest;
+
+ @BeforeEach
+ void setUp() {
+ underTest = new EmailConfigurationService(dbTester.getDbClient());
+ }
+
+ @Test
+ void createConfiguration_whenConfigExists_shouldFail() {
+ dbTester.getDbClient().internalPropertiesDao().save(dbTester.getSession(), EMAIL_CONFIG_SMTP_HOST, "localhost");
+ dbTester.commit();
+
+ EmailConfiguration emailConfiguration = EMAIL_BASIC_CONFIG_BUILDER.build();
+ assertThatThrownBy(() -> underTest.createConfiguration(emailConfiguration))
+ .isInstanceOf(BadRequestException.class)
+ .hasMessage("Email configuration already exists. Only one Email configuration is supported.");
+ }
+
+ @ParameterizedTest
+ @MethodSource("configCreationParamConstraints")
+ void createConfiguration_whenFieldsAreMissing_shouldThrow(EmailConfigurationAuthMethod authMethod, String missingField, String errorMessage) {
+ EmailConfiguration config = new EmailConfiguration(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ "smtpHost",
+ "smtpPort",
+ EmailConfigurationSecurityProtocol.NONE,
+ "fromAddress",
+ "fromName",
+ "subjectPrefix",
+ authMethod,
+ "username",
+ "basicPassword".equals(missingField) ? null : "basicPassword",
+ "oauthAuthenticationHost".equals(missingField) ? null : "oauthAuthenticationHost",
+ "oauthClientId".equals(missingField) ? null : "oauthClientId",
+ "oauthClientSecret".equals(missingField) ? null : "oauthClientSecret",
+ "oauthTenant".equals(missingField) ? null : "oauthTenant"
+ );
+
+ assertThatThrownBy(() -> underTest.createConfiguration(config))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(errorMessage);
+ }
+
+ static Object[][] configCreationParamConstraints() {
+ return new Object[][]{
+ {EmailConfigurationAuthMethod.BASIC, "basicPassword", "Password is required."},
+ {EmailConfigurationAuthMethod.OAUTH, "oauthAuthenticationHost", "OAuth authentication host is required."},
+ {EmailConfigurationAuthMethod.OAUTH, "oauthClientId", "OAuth client id is required."},
+ {EmailConfigurationAuthMethod.OAUTH, "oauthClientSecret", "OAuth client secret is required."},
+ {EmailConfigurationAuthMethod.OAUTH, "oauthTenant", "OAuth tenant is required."}
+ };
+ }
+
+ @Test
+ void createConfiguration_whenConfigDoesNotExist_shouldCreateConfig() {
+ EmailConfiguration configuration = EMAIL_BASIC_CONFIG_BUILDER.build();
+ EmailConfiguration createdConfig = underTest.createConfiguration(configuration);
+
+ assertThatConfigurationIsCorrect(configuration, createdConfig);
+ }
+
+ @Test
+ void getConfiguration_whenWrongId_shouldThrow() {
+ assertThatThrownBy(() -> underTest.getConfiguration("wrongId"))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Email configuration with id wrongId not found");
+ }
+
+ @Test
+ void getConfiguration_whenNoConfig_shouldThrow() {
+ assertThatThrownBy(() -> underTest.getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Email configuration doesn't exist.");
+ }
+
+ @Test
+ void getConfiguration_whenConfigExists_shouldReturnConfig() {
+ EmailConfiguration configuration = EMAIL_BASIC_CONFIG_BUILDER.build();
+ underTest.createConfiguration(configuration);
+
+ EmailConfiguration retrievedConfig = underTest.getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID);
+
+ assertThatConfigurationIsCorrect(configuration, retrievedConfig);
+ }
+
+ @Test
+ void findConfiguration_whenNoConfig_shouldReturnEmpty() {
+ assertThat(underTest.findConfigurations()).isEmpty();
+ }
+
+ @Test
+ void findConfiguration_whenConfigExists_shouldReturnConfig() {
+ EmailConfiguration configuration = underTest.createConfiguration(EMAIL_BASIC_CONFIG_BUILDER.build());
+
+ assertThat(underTest.findConfigurations()).contains(configuration);
+ }
+
+ @Test
+ void updateConfiguration_whenConfigDoesNotExist_shouldThrow() {
+ UpdateEmailConfigurationRequest updateRequest = getUpdateEmailConfigurationRequestFromConfig(EMAIL_OAUTH_CONFIG_BUILDER.build());
+
+ assertThatThrownBy(() -> underTest.updateConfiguration(updateRequest))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Email configuration doesn't exist.");
+ }
+
+ @ParameterizedTest
+ @MethodSource("configUpdateParamConstraints")
+ void updateConfiguration_shouldApplyParamConstraints(ConfigTypeAndOrigin configTypeAndOrigin, List<Param> params, boolean shouldThrow, String errorMessage) {
+ UpdateEmailConfigurationRequest updateRequest = prepareUpdateRequestFromParams(configTypeAndOrigin, params);
+
+ if (shouldThrow) {
+ assertThatThrownBy(() -> underTest.updateConfiguration(updateRequest))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(errorMessage);
+ } else {
+ EmailConfiguration updatedConfig = underTest.updateConfiguration(updateRequest);
+ assertUpdatesMadeFromParams(configTypeAndOrigin, params, updatedConfig);
+ }
+ }
+
+ static Object[][] configUpdateParamConstraints() {
+ return new Object[][]{
+ // OAuth URLs update constraints
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_CONFIG, List.of(), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("host", ParamOrigin.REQUEST, "newHost")), true, OAUTH_URLS_ERROR_MESSAGE},
+ {ConfigTypeAndOrigin.OAUTH_BY_CONFIG, List.of(new Param("host", ParamOrigin.REQUEST, "newHost")), true, OAUTH_URLS_ERROR_MESSAGE},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("host", ParamOrigin.REQUEST, "newHost"), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_CONFIG, List.of(new Param("host", ParamOrigin.REQUEST, "newHost"), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthAuthenticationHost", ParamOrigin.REQUEST, "newAuthHost")), true, OAUTH_URLS_ERROR_MESSAGE},
+ {ConfigTypeAndOrigin.OAUTH_BY_CONFIG, List.of(new Param("oauthAuthenticationHost", ParamOrigin.REQUEST, "newAuthHost")), true, OAUTH_URLS_ERROR_MESSAGE},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthAuthenticationHost", ParamOrigin.REQUEST, "newAuthHost"), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_CONFIG, List.of(new Param("oauthAuthenticationHost", ParamOrigin.REQUEST, "newAuthHost"), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), false, ""},
+ // Basic URLs update constraints
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(), false, ""},
+ {ConfigTypeAndOrigin.BASIC_BY_CONFIG, List.of(), false, ""},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("host", ParamOrigin.REQUEST, "newHost")), true, BASIC_URLS_ERROR_MESSAGE},
+ {ConfigTypeAndOrigin.BASIC_BY_CONFIG, List.of(new Param("host", ParamOrigin.REQUEST, "newHost")), true, BASIC_URLS_ERROR_MESSAGE},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("host", ParamOrigin.REQUEST, "newHost"), new Param("basicPassword", ParamOrigin.REQUEST, "newPassword")), false, ""},
+ {ConfigTypeAndOrigin.BASIC_BY_CONFIG, List.of(new Param("host", ParamOrigin.REQUEST, "newHost"), new Param("basicPassword", ParamOrigin.REQUEST, "newPassword")), false, ""},
+ // OAuth param existence update constraints
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthAuthenticationHost", ParamOrigin.CONFIG, "")), true, "OAuth authentication host is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthAuthenticationHost", ParamOrigin.CONFIG, ""), new Param("oauthAuthenticationHost", ParamOrigin.REQUEST, ""), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), true, "OAuth authentication host is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthAuthenticationHost", ParamOrigin.CONFIG, ""), new Param("oauthAuthenticationHost", ParamOrigin.REQUEST, "newHost"), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthClientId", ParamOrigin.CONFIG, "")), true, "OAuth client id is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthClientId", ParamOrigin.CONFIG, ""), new Param("oauthClientId", ParamOrigin.REQUEST, "")), true, "OAuth client id is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthClientId", ParamOrigin.CONFIG, ""), new Param("oauthClientId", ParamOrigin.REQUEST, "newId")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthClientSecret", ParamOrigin.CONFIG, "")), true, "OAuth client secret is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthClientSecret", ParamOrigin.CONFIG, ""), new Param("oauthClientSecret", ParamOrigin.REQUEST, "")), true, "OAuth client secret is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthClientSecret", ParamOrigin.CONFIG, ""), new Param("oauthClientSecret", ParamOrigin.REQUEST, "newSecret")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthTenant", ParamOrigin.CONFIG, "")), true, "OAuth tenant is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthTenant", ParamOrigin.CONFIG, ""), new Param("oauthTenant", ParamOrigin.REQUEST, "")), true, "OAuth tenant is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("oauthTenant", ParamOrigin.CONFIG, ""), new Param("oauthTenant", ParamOrigin.REQUEST, "newTenant")), false, ""},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("username", ParamOrigin.CONFIG, "")), true, "Username is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("username", ParamOrigin.CONFIG, ""), new Param("username", ParamOrigin.REQUEST, "")), true, "Username is required."},
+ {ConfigTypeAndOrigin.OAUTH_BY_REQUEST, List.of(new Param("username", ParamOrigin.CONFIG, ""), new Param("username", ParamOrigin.REQUEST, "newUsername")), false, ""},
+ // Basic param existence update constraints
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("username", ParamOrigin.CONFIG, "")), true, "Username is required."},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("username", ParamOrigin.CONFIG, ""), new Param("username", ParamOrigin.REQUEST, "")), true, "Username is required."},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("username", ParamOrigin.CONFIG, ""), new Param("username", ParamOrigin.REQUEST, "newUsername")), false, ""},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("basicPassword", ParamOrigin.CONFIG, "")), true, "Password is required."},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("basicPassword", ParamOrigin.CONFIG, ""), new Param("basicPassword", ParamOrigin.REQUEST, "")), true, "Password is required."},
+ {ConfigTypeAndOrigin.BASIC_BY_REQUEST, List.of(new Param("basicPassword", ParamOrigin.CONFIG, ""), new Param("basicPassword", ParamOrigin.REQUEST, "newPassword")), false, ""},
+ };
+ }
+
+ @Test
+ void updateConfiguration_whenConfigExists_shouldUpdateConfig() {
+ underTest.createConfiguration(EMAIL_BASIC_CONFIG_BUILDER.build());
+ EmailConfiguration newConfig = EMAIL_OAUTH_CONFIG_BUILDER.build();
+ UpdateEmailConfigurationRequest updateRequest = getUpdateEmailConfigurationRequestFromConfig(newConfig);
+
+ EmailConfiguration updatedConfig = underTest.updateConfiguration(updateRequest);
+
+ assertThatConfigurationIsCorrect(newConfig, updatedConfig);
+ }
+
+ private static UpdateEmailConfigurationRequest getUpdateEmailConfigurationRequestFromConfig(EmailConfiguration updatedConfig) {
+ return builder()
+ .emailConfigurationId(updatedConfig.id())
+ .host(withValueOrThrow(updatedConfig.host()))
+ .port(withValueOrThrow(updatedConfig.port()))
+ .securityProtocol(withValueOrThrow(updatedConfig.securityProtocol()))
+ .fromAddress(withValueOrThrow(updatedConfig.fromAddress()))
+ .fromName(withValueOrThrow(updatedConfig.fromName()))
+ .subjectPrefix(withValueOrThrow(updatedConfig.subjectPrefix()))
+ .authMethod(withValueOrThrow(updatedConfig.authMethod()))
+ .username(withValueOrThrow(updatedConfig.username()))
+ .basicPassword(withValueOrThrow(updatedConfig.basicPassword()))
+ .oauthAuthenticationHost(withValueOrThrow(updatedConfig.oauthAuthenticationHost()))
+ .oauthClientId(withValueOrThrow(updatedConfig.oauthClientId()))
+ .oauthClientSecret(withValueOrThrow(updatedConfig.oauthClientSecret()))
+ .oauthTenant(withValueOrThrow(updatedConfig.oauthTenant()))
+ .build();
+ }
+
+ private void assertThatConfigurationIsCorrect(EmailConfiguration expectedConfig, EmailConfiguration actualConfig) {
+ assertThat(actualConfig.id()).isEqualTo(expectedConfig.id());
+ assertThat(actualConfig.host()).isEqualTo(expectedConfig.host());
+ assertThat(actualConfig.port()).isEqualTo(expectedConfig.port());
+ assertThat(actualConfig.securityProtocol()).isEqualTo(expectedConfig.securityProtocol());
+ assertThat(actualConfig.fromAddress()).isEqualTo(expectedConfig.fromAddress());
+ assertThat(actualConfig.fromName()).isEqualTo(expectedConfig.fromName());
+ assertThat(actualConfig.subjectPrefix()).isEqualTo(expectedConfig.subjectPrefix());
+ assertThat(actualConfig.authMethod()).isEqualTo(expectedConfig.authMethod());
+ assertThat(actualConfig.username()).isEqualTo(expectedConfig.username());
+ assertThat(actualConfig.basicPassword()).isEqualTo(expectedConfig.basicPassword());
+ assertThat(actualConfig.oauthAuthenticationHost()).isEqualTo(expectedConfig.oauthAuthenticationHost());
+ assertThat(actualConfig.oauthClientId()).isEqualTo(expectedConfig.oauthClientId());
+ assertThat(actualConfig.oauthClientSecret()).isEqualTo(expectedConfig.oauthClientSecret());
+ assertThat(actualConfig.oauthTenant()).isEqualTo(expectedConfig.oauthTenant());
+ assertThat(actualConfig.oauthScope()).isEqualTo(expectedConfig.oauthScope());
+ assertThat(actualConfig.oauthGrant()).isEqualTo(expectedConfig.oauthGrant());
+ }
+
+ @Test
+ void deleteConfiguration_whenConfigDoesNotExist_shouldThrow() {
+ assertThatThrownBy(() -> underTest.deleteConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Email configuration doesn't exist.");
+ }
+
+ @Test
+ void deleteConfiguration_whenConfigExists_shouldDeleteConfig() {
+ underTest.createConfiguration(EMAIL_BASIC_CONFIG_BUILDER.build());
+
+ underTest.deleteConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID);
+
+ assertThatThrownBy(() -> underTest.getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessage("Email configuration doesn't exist.");
+ }
+
+
+ private UpdateEmailConfigurationRequest prepareUpdateRequestFromParams(ConfigTypeAndOrigin configTypeAndOrigin, List<Param> params) {
+ createOriginalConfiguration(configTypeAndOrigin, params);
+ UpdateEmailConfigurationRequest.Builder requestBuilder = getOriginalBuilder();
+
+ switch (configTypeAndOrigin) {
+ case OAUTH_BY_REQUEST, OAUTH_BY_CONFIG:
+ requestBuilder.authMethod(withValueOrThrow(EmailConfigurationAuthMethod.OAUTH));
+ break;
+ case BASIC_BY_REQUEST, BASIC_BY_CONFIG:
+ requestBuilder.authMethod(withValueOrThrow(EmailConfigurationAuthMethod.BASIC));
+ break;
+ default:
+ throw new IllegalArgumentException(format("Invalid test input: config %s not supported", configTypeAndOrigin.name()));
+ }
+
+ for (Param param : params) {
+ if (param.paramOrigin.equals(ParamOrigin.REQUEST)) {
+ switch (param.paramName()) {
+ case "host":
+ requestBuilder.host(withValueOrThrow(param.value()));
+ break;
+ case "basicPassword":
+ requestBuilder.basicPassword(withValueOrThrow(param.value()));
+ break;
+ case "username":
+ requestBuilder.username(withValueOrThrow(param.value()));
+ break;
+ case "oauthAuthenticationHost":
+ requestBuilder.oauthAuthenticationHost(withValueOrThrow(param.value()));
+ break;
+ case "oauthClientSecret":
+ requestBuilder.oauthClientSecret(withValueOrThrow(param.value()));
+ break;
+ case "oauthClientId":
+ requestBuilder.oauthClientId(withValueOrThrow(param.value()));
+ break;
+ case "oauthTenant":
+ requestBuilder.oauthTenant(withValueOrThrow(param.value()));
+ break;
+ default:
+ throw new IllegalArgumentException(format("Invalid test input: param %s not supported.", param.paramName()));
+ }
+ }
+ }
+
+ return requestBuilder.build();
+ }
+
+ private void createOriginalConfiguration(ConfigTypeAndOrigin configTypeAndOrigin, List<Param> params) {
+ EmailConfigurationBuilder configBuilder;
+
+ if (configTypeAndOrigin == ConfigTypeAndOrigin.OAUTH_BY_CONFIG || configTypeAndOrigin == ConfigTypeAndOrigin.OAUTH_BY_REQUEST) {
+ configBuilder = EMAIL_OAUTH_CONFIG_BUILDER;
+ } else {
+ configBuilder = EMAIL_BASIC_CONFIG_BUILDER;
+ }
+
+ underTest.createConfiguration(configBuilder.build());
+
+ // We manually alter the Param config to bypass service constraints of EmailConfigurationService.createConfiguration()
+ Map<String, String> paramNameToPropertyKey = Map.of(
+ "username", EMAIL_CONFIG_SMTP_USERNAME,
+ "basicPassword", EMAIL_CONFIG_SMTP_PASSWORD,
+ "oauthAuthenticationHost", EMAIL_CONFIG_SMTP_OAUTH_HOST,
+ "oauthClientId", EMAIL_CONFIG_SMTP_OAUTH_CLIENTID,
+ "oauthClientSecret", EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET,
+ "oauthTenant", EMAIL_CONFIG_SMTP_OAUTH_TENANT
+ );
+ params.stream()
+ .filter(param -> param.paramOrigin.equals(ParamOrigin.CONFIG))
+ .forEach(param -> setInternalProperty(dbTester.getSession(), paramNameToPropertyKey.get(param.paramName()), param.value));
+ dbTester.commit();
+
+ }
+
+ private void setInternalProperty(DbSession dbSession, String propertyName, @Nullable String value) {
+ if (StringUtils.isBlank(value)) {
+ dbTester.getDbClient().internalPropertiesDao().delete(dbSession, propertyName);
+ } else {
+ dbTester.getDbClient().internalPropertiesDao().save(dbSession, propertyName, value);
+ }
+ }
+
+ private static UpdateEmailConfigurationRequest.Builder getOriginalBuilder() {
+ return builder()
+ .emailConfigurationId(UNIQUE_EMAIL_CONFIGURATION_ID)
+ .host(undefined())
+ .port(undefined())
+ .securityProtocol(undefined())
+ .fromAddress(undefined())
+ .fromName(undefined())
+ .subjectPrefix(undefined())
+ .authMethod(undefined())
+ .username(undefined())
+ .basicPassword(undefined())
+ .oauthAuthenticationHost(undefined())
+ .oauthClientId(undefined())
+ .oauthClientSecret(undefined())
+ .oauthTenant(undefined());
+ }
+
+ private void assertUpdatesMadeFromParams(ConfigTypeAndOrigin configTypeAndOrigin, List<Param> params, EmailConfiguration updatedConfig) {
+ for (Param param : params) {
+ if (param.paramOrigin.equals(ParamOrigin.REQUEST)) {
+ switch (param.paramName()) {
+ case "host":
+ assertThat(updatedConfig.host()).isEqualTo(param.value());
+ break;
+ case "basicPassword":
+ assertThat(updatedConfig.basicPassword()).isEqualTo(param.value());
+ break;
+ case "username":
+ assertThat(updatedConfig.username()).isEqualTo(param.value());
+ break;
+ case "oauthAuthenticationHost":
+ assertThat(updatedConfig.oauthAuthenticationHost()).isEqualTo(param.value());
+ break;
+ case "oauthClientId":
+ assertThat(updatedConfig.oauthClientId()).isEqualTo(param.value());
+ break;
+ case "oauthClientSecret":
+ assertThat(updatedConfig.oauthClientSecret()).isEqualTo(param.value());
+ break;
+ case "oauthTenant":
+ assertThat(updatedConfig.oauthTenant()).isEqualTo(param.value());
+ break;
+ default:
+ throw new IllegalArgumentException(format("Invalid test input: param %s not supported.", param.paramName()));
+ }
+ }
+ }
+
+ if (configTypeAndOrigin == ConfigTypeAndOrigin.OAUTH_BY_REQUEST) {
+ assertThat(updatedConfig.authMethod()).isEqualTo(EmailConfigurationAuthMethod.OAUTH);
+ }
+ if (configTypeAndOrigin == ConfigTypeAndOrigin.BASIC_BY_REQUEST) {
+ assertThat(updatedConfig.authMethod()).isEqualTo(EmailConfigurationAuthMethod.BASIC);
+ }
+ }
+
+ private enum ConfigTypeAndOrigin {
+ BASIC_BY_CONFIG, BASIC_BY_REQUEST, OAUTH_BY_CONFIG, OAUTH_BY_REQUEST;
+ }
+
+ private enum ParamOrigin {
+ REQUEST, CONFIG;
+ }
+
+ private record Param(String paramName, ParamOrigin paramOrigin, @Nullable String value) {}
+
+}
+
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfiguration.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfiguration.java
new file mode 100644
index 00000000000..8b5a2d19745
--- /dev/null
+++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfiguration.java
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+import javax.annotation.Nullable;
+
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_GRANT_DEFAULT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT;
+
+public record EmailConfiguration(
+ String id,
+ String host,
+ String port,
+ EmailConfigurationSecurityProtocol securityProtocol,
+ String fromAddress,
+ String fromName,
+ String subjectPrefix,
+ EmailConfigurationAuthMethod authMethod,
+ String username,
+ @Nullable String basicPassword,
+ @Nullable String oauthAuthenticationHost,
+ @Nullable String oauthClientId,
+ @Nullable String oauthClientSecret,
+ @Nullable String oauthTenant,
+ @Nullable String oauthScope,
+ @Nullable String oauthGrant
+) {
+
+ public EmailConfiguration(String id, String host, String port, EmailConfigurationSecurityProtocol securityProtocol, String fromAddress, String fromName, String subjectPrefix,
+ EmailConfigurationAuthMethod authMethod, String username, @Nullable String basicPassword, @Nullable String oauthAuthenticationHost,
+ @Nullable String oauthClientId, @Nullable String oauthClientSecret, @Nullable String oauthTenant) {
+ this(id, host, port, securityProtocol, fromAddress, fromName, subjectPrefix, authMethod, username, basicPassword, oauthAuthenticationHost, oauthClientId,
+ oauthClientSecret, oauthTenant, EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT, EMAIL_CONFIG_SMTP_OAUTH_GRANT_DEFAULT);
+ }
+}
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationAuthMethod.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationAuthMethod.java
new file mode 100644
index 00000000000..2269794ac2b
--- /dev/null
+++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationAuthMethod.java
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+public enum EmailConfigurationAuthMethod {
+ BASIC, OAUTH
+}
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationSecurityProtocol.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationSecurityProtocol.java
new file mode 100644
index 00000000000..7e8eaf57c9e
--- /dev/null
+++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationSecurityProtocol.java
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+public enum EmailConfigurationSecurityProtocol {
+ NONE, SSLTLS, STARTTLS
+}
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationService.java
new file mode 100644
index 00000000000..fdb90787dc7
--- /dev/null
+++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationService.java
@@ -0,0 +1,284 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+import java.util.List;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.apache.commons.lang.StringUtils;
+import org.sonar.api.server.ServerSide;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.server.common.NonNullUpdatedValue;
+import org.sonar.server.common.UpdatedValue;
+import org.sonar.server.exceptions.BadRequestException;
+import org.sonar.server.exceptions.NotFoundException;
+
+import static java.lang.String.format;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_FROM;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_FROM_NAME;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_PREFIX;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_AUTH_METHOD;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_HOST;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_CLIENTID;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_GRANT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_GRANT_DEFAULT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_HOST;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_SCOPE;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_OAUTH_TENANT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_PASSWORD;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_PORT;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_SECURE_CONNECTION;
+import static org.sonar.server.email.EmailSmtpConfiguration.EMAIL_CONFIG_SMTP_USERNAME;
+import static org.sonarqube.ws.WsUtils.checkArgument;
+
+@ServerSide
+public class EmailConfigurationService {
+
+ private static final List<String> EMAIL_CONFIGURATION_PROPERTIES = List.of(
+ EMAIL_CONFIG_SMTP_HOST,
+ EMAIL_CONFIG_SMTP_PORT,
+ EMAIL_CONFIG_SMTP_SECURE_CONNECTION,
+ EMAIL_CONFIG_FROM,
+ EMAIL_CONFIG_FROM_NAME,
+ EMAIL_CONFIG_PREFIX,
+ EMAIL_CONFIG_SMTP_AUTH_METHOD,
+ EMAIL_CONFIG_SMTP_USERNAME,
+ EMAIL_CONFIG_SMTP_PASSWORD,
+ EMAIL_CONFIG_SMTP_OAUTH_HOST,
+ EMAIL_CONFIG_SMTP_OAUTH_CLIENTID,
+ EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET,
+ EMAIL_CONFIG_SMTP_OAUTH_TENANT,
+ EMAIL_CONFIG_SMTP_OAUTH_SCOPE,
+ EMAIL_CONFIG_SMTP_OAUTH_GRANT
+ );
+
+ public static final String UNIQUE_EMAIL_CONFIGURATION_ID = "email-configuration";
+
+ private final DbClient dbClient;
+
+ public EmailConfigurationService(DbClient dbClient) {
+ this.dbClient = dbClient;
+ }
+
+ public EmailConfiguration createConfiguration(EmailConfiguration configuration) {
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ throwIfConfigurationAlreadyExists(dbSession);
+ throwIfParamsConstraintsAreNotMetForCreation(configuration);
+
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_HOST, configuration.host());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_PORT, configuration.port());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_SECURE_CONNECTION, configuration.securityProtocol().name());
+ setInternalProperty(dbSession, EMAIL_CONFIG_FROM, configuration.fromAddress());
+ setInternalProperty(dbSession, EMAIL_CONFIG_FROM_NAME, configuration.fromName());
+ setInternalProperty(dbSession, EMAIL_CONFIG_PREFIX, configuration.subjectPrefix());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_AUTH_METHOD, configuration.authMethod().name());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_USERNAME, configuration.username());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_PASSWORD, configuration.basicPassword());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_HOST, configuration.oauthAuthenticationHost());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_CLIENTID, configuration.oauthClientId());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET, configuration.oauthClientSecret());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_TENANT, configuration.oauthTenant());
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_SCOPE, EMAIL_CONFIG_SMTP_OAUTH_SCOPE_DEFAULT);
+ setInternalProperty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_GRANT, EMAIL_CONFIG_SMTP_OAUTH_GRANT_DEFAULT);
+
+ EmailConfiguration createdConfiguration = getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID, dbSession);
+ dbSession.commit();
+ return createdConfiguration;
+ }
+ }
+
+ private static void throwIfParamsConstraintsAreNotMetForCreation(EmailConfiguration configuration) {
+ if (configuration.authMethod().equals(EmailConfigurationAuthMethod.OAUTH)) {
+ checkArgument(StringUtils.isNotEmpty(configuration.oauthAuthenticationHost()), "OAuth authentication host is required.");
+ checkArgument(StringUtils.isNotEmpty(configuration.oauthClientId()), "OAuth client id is required.");
+ checkArgument(StringUtils.isNotEmpty(configuration.oauthClientSecret()), "OAuth client secret is required.");
+ checkArgument(StringUtils.isNotEmpty(configuration.oauthTenant()), "OAuth tenant is required.");
+ } else if (configuration.authMethod().equals(EmailConfigurationAuthMethod.BASIC)) {
+ checkArgument(StringUtils.isNotEmpty(configuration.basicPassword()), "Password is required.");
+ }
+ }
+
+ private void throwIfConfigurationAlreadyExists(DbSession dbSession) {
+ if (configurationExists(dbSession)) {
+ throw BadRequestException.create("Email configuration already exists. Only one Email configuration is supported.");
+ }
+ }
+
+ public EmailConfiguration getConfiguration(String id) {
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ throwIfNotUniqueConfigurationId(id);
+ throwIfConfigurationDoesntExist(dbSession);
+ return getConfiguration(id, dbSession);
+ }
+ }
+
+ private EmailConfiguration getConfiguration(String id, DbSession dbSession) {
+ throwIfNotUniqueConfigurationId(id);
+ throwIfConfigurationDoesntExist(dbSession);
+ return new EmailConfiguration(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_HOST),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_PORT),
+ EmailConfigurationSecurityProtocol.valueOf(getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_SECURE_CONNECTION)),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_FROM),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_FROM_NAME),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_PREFIX),
+ EmailConfigurationAuthMethod.valueOf(getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_AUTH_METHOD)),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_USERNAME),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_PASSWORD),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_HOST),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_CLIENTID),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_TENANT),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_SCOPE),
+ getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_OAUTH_GRANT));
+ }
+
+ private static void throwIfNotUniqueConfigurationId(String id) {
+ if (!UNIQUE_EMAIL_CONFIGURATION_ID.equals(id)) {
+ throw new NotFoundException(format("Email configuration with id %s not found", id));
+ }
+ }
+
+ private String getStringInternalPropertyOrEmpty(DbSession dbSession, String property) {
+ return dbClient.internalPropertiesDao().selectByKey(dbSession, property).orElse("");
+ }
+
+ public Optional<EmailConfiguration> findConfigurations() {
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ if (configurationExists(dbSession)) {
+ return Optional.of(getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID, dbSession));
+ }
+ return Optional.empty();
+ }
+ }
+
+ public EmailConfiguration updateConfiguration(UpdateEmailConfigurationRequest updateRequest) {
+ try (DbSession dbSession = dbClient.openSession(true)) {
+ throwIfConfigurationDoesntExist(dbSession);
+ EmailConfiguration existingConfig = getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID, dbSession);
+ throwIfUrlIsUpdatedWithoutCredentials(existingConfig, updateRequest);
+ throwIfParamsConstraintsAreNotMetForUpdate(existingConfig, updateRequest);
+
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_HOST, updateRequest.host());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_PORT, updateRequest.port());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_SECURE_CONNECTION, updateRequest.securityProtocol().map(EmailConfigurationSecurityProtocol::name));
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_FROM, updateRequest.fromAddress());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_FROM_NAME, updateRequest.fromName());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_PREFIX, updateRequest.subjectPrefix());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_AUTH_METHOD, updateRequest.authMethod().map(EmailConfigurationAuthMethod::name));
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_USERNAME, updateRequest.username());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_PASSWORD, updateRequest.basicPassword());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_OAUTH_HOST, updateRequest.oauthAuthenticationHost());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_OAUTH_CLIENTID, updateRequest.oauthClientId());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_OAUTH_CLIENTSECRET, updateRequest.oauthClientSecret());
+ setInternalIfDefined(dbSession, EMAIL_CONFIG_SMTP_OAUTH_TENANT, updateRequest.oauthTenant());
+
+ dbSession.commit();
+
+ return getConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID, dbSession);
+ }
+ }
+
+ private static void throwIfUrlIsUpdatedWithoutCredentials(EmailConfiguration existingConfig, UpdateEmailConfigurationRequest request) {
+ if (isOauthDefinedByExistingConfigOrRequest(existingConfig, request)) {
+ // For OAuth config, we make sure that the client secret is provided when the host or authentication host is updated
+ if (isRequestParameterDefined(request.host()) || isRequestParameterDefined(request.oauthAuthenticationHost())) {
+ checkArgument(isRequestParameterDefined(request.oauthClientSecret()), "For security reasons, OAuth urls can't be updated without providing the client secret.");
+ }
+ } else {
+ // For Basic config, we make sure that the password is provided when the host is updated
+ if (isRequestParameterDefined(request.host())) {
+ checkArgument(isRequestParameterDefined(request.basicPassword()), "For security reasons, the host can't be updated without providing the password.");
+ }
+ }
+ }
+
+ private static void throwIfParamsConstraintsAreNotMetForUpdate(EmailConfiguration existingConfig, UpdateEmailConfigurationRequest updateRequest) {
+ checkArgument(isFieldDefinedByExistingConfigOrRequest(existingConfig.username(), updateRequest.username()),
+ "Username is required.");
+ if (isOauthDefinedByExistingConfigOrRequest(existingConfig, updateRequest)) {
+ checkArgument(isFieldDefinedByExistingConfigOrRequest(existingConfig.oauthAuthenticationHost(), updateRequest.oauthAuthenticationHost()),
+ "OAuth authentication host is required.");
+ checkArgument(isFieldDefinedByExistingConfigOrRequest(existingConfig.oauthClientId(), updateRequest.oauthClientId()),
+ "OAuth client id is required.");
+ checkArgument(isFieldDefinedByExistingConfigOrRequest(existingConfig.oauthClientSecret(), updateRequest.oauthClientSecret()),
+ "OAuth client secret is required.");
+ checkArgument(isFieldDefinedByExistingConfigOrRequest(existingConfig.oauthTenant(), updateRequest.oauthTenant()),
+ "OAuth tenant is required.");
+ } else {
+ checkArgument(isFieldDefinedByExistingConfigOrRequest(existingConfig.basicPassword(), updateRequest.basicPassword()),
+ "Password is required.");
+ }
+ }
+
+ private static boolean isFieldDefinedByExistingConfigOrRequest(@Nullable String existingParam, NonNullUpdatedValue<String> requestParam) {
+ return StringUtils.isNotEmpty(existingParam) || (requestParam.isDefined() && !requestParam.contains(""));
+ }
+
+ private static boolean isOauthDefinedByExistingConfigOrRequest(EmailConfiguration existingConfig, UpdateEmailConfigurationRequest request) {
+ // Either the request update the config to OAuth, or the existing config is OAuth
+ if (isRequestParameterDefined(request.authMethod())) {
+ return request.authMethod().contains(EmailConfigurationAuthMethod.OAUTH);
+ }
+ return existingConfig.authMethod().equals(EmailConfigurationAuthMethod.OAUTH);
+ }
+
+ private static boolean isRequestParameterDefined(@Nullable NonNullUpdatedValue<?> parameter) {
+ return parameter != null && parameter.isDefined();
+ }
+
+ public void deleteConfiguration(String id) {
+ throwIfNotUniqueConfigurationId(id);
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ throwIfConfigurationDoesntExist(dbSession);
+ EMAIL_CONFIGURATION_PROPERTIES.forEach(propertyKey -> dbClient.internalPropertiesDao().delete(dbSession, propertyKey));
+ dbSession.commit();
+ }
+ }
+
+ private void throwIfConfigurationDoesntExist(DbSession dbSession) {
+ if (!configurationExists(dbSession)) {
+ throw new NotFoundException("Email configuration doesn't exist.");
+ }
+ }
+
+ private boolean configurationExists(DbSession dbSession) {
+ String property = getStringInternalPropertyOrEmpty(dbSession, EMAIL_CONFIG_SMTP_HOST);
+ return StringUtils.isNotEmpty(property);
+ }
+
+ private void setInternalIfDefined(DbSession dbSession, String propertyKey, @Nullable UpdatedValue<String> value) {
+ if (value != null) {
+ value.applyIfDefined(propertyValue -> setInternalProperty(dbSession, propertyKey, propertyValue));
+ }
+ }
+
+ private void setInternalProperty(DbSession dbSession, String propertyKey, @Nullable String value) {
+ if (StringUtils.isNotEmpty(value)) {
+ dbClient.internalPropertiesDao().save(dbSession, propertyKey, value);
+ } else {
+ dbClient.internalPropertiesDao().delete(dbSession, propertyKey);
+ }
+ }
+}
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/UpdateEmailConfigurationRequest.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/UpdateEmailConfigurationRequest.java
new file mode 100644
index 00000000000..3f709386ccb
--- /dev/null
+++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/UpdateEmailConfigurationRequest.java
@@ -0,0 +1,139 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.common.email.config;
+
+import org.sonar.server.common.NonNullUpdatedValue;
+
+public record UpdateEmailConfigurationRequest(
+ String emailConfigurationId,
+ NonNullUpdatedValue<String> host,
+ NonNullUpdatedValue<String> port,
+ NonNullUpdatedValue<EmailConfigurationSecurityProtocol> securityProtocol,
+ NonNullUpdatedValue<String> fromAddress,
+ NonNullUpdatedValue<String> fromName,
+ NonNullUpdatedValue<String> subjectPrefix,
+ NonNullUpdatedValue<EmailConfigurationAuthMethod> authMethod,
+ NonNullUpdatedValue<String> username,
+ NonNullUpdatedValue<String> basicPassword,
+ NonNullUpdatedValue<String> oauthAuthenticationHost,
+ NonNullUpdatedValue<String> oauthClientId,
+ NonNullUpdatedValue<String> oauthClientSecret,
+ NonNullUpdatedValue<String> oauthTenant
+) {
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String emailConfigurationId;
+ private NonNullUpdatedValue<String> host;
+ private NonNullUpdatedValue<String> port;
+ private NonNullUpdatedValue<EmailConfigurationSecurityProtocol> securityProtocol;
+ private NonNullUpdatedValue<String> fromAddress;
+ private NonNullUpdatedValue<String> fromName;
+ private NonNullUpdatedValue<String> subjectPrefix;
+ private NonNullUpdatedValue<EmailConfigurationAuthMethod> authMethod;
+ private NonNullUpdatedValue<String> username;
+ private NonNullUpdatedValue<String> basicPassword;
+ private NonNullUpdatedValue<String> oauthAuthenticationHost;
+ private NonNullUpdatedValue<String> oauthClientId;
+ private NonNullUpdatedValue<String> oauthClientSecret;
+ private NonNullUpdatedValue<String> oauthTenant;
+
+ private Builder() {
+ }
+
+ public Builder emailConfigurationId(String emailConfigurationId) {
+ this.emailConfigurationId = emailConfigurationId;
+ return this;
+ }
+
+ public Builder host(NonNullUpdatedValue<String> host) {
+ this.host = host;
+ return this;
+ }
+
+ public Builder port(NonNullUpdatedValue<String> port) {
+ this.port = port;
+ return this;
+ }
+
+ public Builder securityProtocol(NonNullUpdatedValue<EmailConfigurationSecurityProtocol> securityProtocol) {
+ this.securityProtocol = securityProtocol;
+ return this;
+ }
+
+ public Builder fromAddress(NonNullUpdatedValue<String> fromAddress) {
+ this.fromAddress = fromAddress;
+ return this;
+ }
+
+ public Builder fromName(NonNullUpdatedValue<String> fromName) {
+ this.fromName = fromName;
+ return this;
+ }
+
+ public Builder subjectPrefix(NonNullUpdatedValue<String> subjectPrefix) {
+ this.subjectPrefix = subjectPrefix;
+ return this;
+ }
+
+ public Builder authMethod(NonNullUpdatedValue<EmailConfigurationAuthMethod> authMethod) {
+ this.authMethod = authMethod;
+ return this;
+ }
+
+ public Builder username(NonNullUpdatedValue<String> username) {
+ this.username = username;
+ return this;
+ }
+
+ public Builder basicPassword(NonNullUpdatedValue<String> basicPassword) {
+ this.basicPassword = basicPassword;
+ return this;
+ }
+
+ public Builder oauthAuthenticationHost(NonNullUpdatedValue<String> oauthAuthenticationHost) {
+ this.oauthAuthenticationHost = oauthAuthenticationHost;
+ return this;
+ }
+
+ public Builder oauthClientId(NonNullUpdatedValue<String> oauthClientId) {
+ this.oauthClientId = oauthClientId;
+ return this;
+ }
+
+ public Builder oauthClientSecret(NonNullUpdatedValue<String> oauthClientSecret) {
+ this.oauthClientSecret = oauthClientSecret;
+ return this;
+ }
+
+ public Builder oauthTenant(NonNullUpdatedValue<String> oauthTenant) {
+ this.oauthTenant = oauthTenant;
+ return this;
+ }
+
+ public UpdateEmailConfigurationRequest build() {
+ return new UpdateEmailConfigurationRequest(emailConfigurationId, host, port, securityProtocol, fromAddress, fromName, subjectPrefix, authMethod, username,
+ basicPassword, oauthAuthenticationHost, oauthClientId, oauthClientSecret, oauthTenant);
+ }
+ }
+}
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/package-info.java
new file mode 100644
index 00000000000..dbfa3d62f68
--- /dev/null
+++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.common.email.config;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
index 0bd6af1386b..a705491096d 100644
--- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
@@ -20,39 +20,36 @@
package org.sonar.server.v2;
public class WebApiEndpoints {
- private static final String SYSTEM_ENDPOINTS = "/system";
- public static final String LIVENESS_ENDPOINT = SYSTEM_ENDPOINTS + "/liveness";
- public static final String HEALTH_ENDPOINT = SYSTEM_ENDPOINTS + "/health";
- public static final String DATABASE_MIGRATIONS_ENDPOINT = SYSTEM_ENDPOINTS + "/migrations-status";
+ public static final String JSON_MERGE_PATCH_CONTENT_TYPE = "application/merge-patch+json";
+ public static final String INTERNAL = "internal";
+
+ public static final String SYSTEM_DOMAIN = "/system";
+ public static final String LIVENESS_ENDPOINT = SYSTEM_DOMAIN + "/liveness";
+ public static final String HEALTH_ENDPOINT = SYSTEM_DOMAIN + "/health";
+ public static final String DATABASE_MIGRATIONS_ENDPOINT = SYSTEM_DOMAIN + "/migrations-status";
+ public static final String EMAIL_CONFIGURATION_ENDPOINT = SYSTEM_DOMAIN + "/email-configurations";
public static final String USERS_MANAGEMENT_DOMAIN = "/users-management";
public static final String USER_ENDPOINT = USERS_MANAGEMENT_DOMAIN + "/users";
- public static final String JSON_MERGE_PATCH_CONTENT_TYPE = "application/merge-patch+json";
- public static final String AUTHORIZATIONS_DOMAIN = "/authorizations";
+ public static final String AUTHORIZATIONS_DOMAIN = "/authorizations";
public static final String GROUPS_ENDPOINT = AUTHORIZATIONS_DOMAIN + "/groups";
public static final String GROUP_MEMBERSHIPS_ENDPOINT = AUTHORIZATIONS_DOMAIN + "/group-memberships";
- public static final String DOP_TRANSLATION_DOMAIN = "/dop-translation";
public static final String CLEAN_CODE_POLICY_DOMAIN = "/clean-code-policy";
public static final String RULES_ENDPOINT = CLEAN_CODE_POLICY_DOMAIN + "/rules";
+ public static final String DOP_TRANSLATION_DOMAIN = "/dop-translation";
public static final String GITLAB_CONFIGURATION_ENDPOINT = DOP_TRANSLATION_DOMAIN + "/gitlab-configurations";
-
public static final String GITHUB_CONFIGURATION_ENDPOINT = DOP_TRANSLATION_DOMAIN + "/github-configurations";
-
public static final String BOUND_PROJECTS_ENDPOINT = DOP_TRANSLATION_DOMAIN + "/bound-projects";
-
public static final String PROJECT_BINDINGS_ENDPOINT = DOP_TRANSLATION_DOMAIN + "/project-bindings";
-
public static final String DOP_SETTINGS_ENDPOINT = DOP_TRANSLATION_DOMAIN + "/dop-settings";
- public static final String INTERNAL = "internal";
-
- public static final String ANALYSIS_ENDPOINT = "/analysis";
- public static final String VERSION_ENDPOINT = ANALYSIS_ENDPOINT + "/version";
- public static final String JRE_ENDPOINT = ANALYSIS_ENDPOINT + "/jres";
- public static final String SCANNER_ENGINE_ENDPOINT = ANALYSIS_ENDPOINT + "/engine";
+ public static final String ANALYSIS_DOMAIN = "/analysis";
+ public static final String VERSION_ENDPOINT = ANALYSIS_DOMAIN + "/version";
+ public static final String JRE_ENDPOINT = ANALYSIS_DOMAIN + "/jres";
+ public static final String SCANNER_ENGINE_ENDPOINT = ANALYSIS_DOMAIN + "/engine";
private WebApiEndpoints() {
}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/DefaultEmailConfigurationController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/DefaultEmailConfigurationController.java
new file mode 100644
index 00000000000..debfde60dd6
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/DefaultEmailConfigurationController.java
@@ -0,0 +1,164 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.controller;
+
+import java.util.List;
+import org.apache.commons.lang.StringUtils;
+import org.sonar.server.common.email.config.EmailConfiguration;
+import org.sonar.server.common.email.config.EmailConfigurationAuthMethod;
+import org.sonar.server.common.email.config.EmailConfigurationSecurityProtocol;
+import org.sonar.server.common.email.config.EmailConfigurationService;
+import org.sonar.server.common.email.config.UpdateEmailConfigurationRequest;
+import org.sonar.server.user.UserSession;
+import org.sonar.server.v2.api.email.config.request.EmailConfigurationCreateRestRequest;
+import org.sonar.server.v2.api.email.config.request.EmailConfigurationUpdateRestRequest;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationResource;
+import org.sonar.server.v2.api.email.config.response.EmailConfigurationSearchRestResponse;
+import org.sonar.server.v2.api.response.PageRestResponse;
+
+import static org.sonar.server.common.email.config.EmailConfigurationService.UNIQUE_EMAIL_CONFIGURATION_ID;
+
+public class DefaultEmailConfigurationController implements EmailConfigurationController {
+
+ private final UserSession userSession;
+ private final EmailConfigurationService emailConfigurationService;
+
+ public DefaultEmailConfigurationController(UserSession userSession, EmailConfigurationService emailConfigurationService) {
+ this.userSession = userSession;
+ this.emailConfigurationService = emailConfigurationService;
+ }
+
+ @Override
+ public EmailConfigurationResource createEmailConfiguration(EmailConfigurationCreateRestRequest createRequest) {
+ userSession.checkIsSystemAdministrator();
+ EmailConfiguration createdConfiguration = emailConfigurationService.createConfiguration(toEmailConfiguration(createRequest));
+ return toEmailConfigurationResource(createdConfiguration);
+ }
+
+ private static EmailConfiguration toEmailConfiguration(EmailConfigurationCreateRestRequest createRestRequest) {
+ return new EmailConfiguration(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ createRestRequest.host(),
+ createRestRequest.port(),
+ toSecurityProtocol(createRestRequest.securityProtocol()),
+ createRestRequest.fromAddress(),
+ createRestRequest.fromName(),
+ createRestRequest.subjectPrefix(),
+ toAuthMethod(createRestRequest.authMethod()),
+ createRestRequest.username(),
+ createRestRequest.basicPassword(),
+ createRestRequest.oauthAuthenticationHost(),
+ createRestRequest.oauthClientId(),
+ createRestRequest.oauthClientSecret(),
+ createRestRequest.oauthTenant()
+ );
+ }
+
+ @Override
+ public EmailConfigurationResource getEmailConfiguration(String id) {
+ userSession.checkIsSystemAdministrator();
+ return getEmailConfigurationResource(id);
+ }
+
+ private EmailConfigurationResource getEmailConfigurationResource(String id) {
+ return toEmailConfigurationResource(emailConfigurationService.getConfiguration(id));
+ }
+
+ @Override
+ public EmailConfigurationSearchRestResponse searchEmailConfigurations() {
+ userSession.checkIsSystemAdministrator();
+
+ List<EmailConfigurationResource> emailConfigurationResources = emailConfigurationService.findConfigurations()
+ .stream()
+ .map(DefaultEmailConfigurationController::toEmailConfigurationResource)
+ .toList();
+
+ PageRestResponse pageRestResponse = new PageRestResponse(1, 1000, emailConfigurationResources.size());
+ return new EmailConfigurationSearchRestResponse(emailConfigurationResources, pageRestResponse);
+ }
+
+ @Override
+ public EmailConfigurationResource updateEmailConfiguration(String id, EmailConfigurationUpdateRestRequest updateRequest) {
+ userSession.checkIsSystemAdministrator();
+
+ UpdateEmailConfigurationRequest updateEmailConfigurationRequest = toUpdateEmailConfigurationRequest(id, updateRequest);
+ return toEmailConfigurationResource(emailConfigurationService.updateConfiguration(updateEmailConfigurationRequest));
+ }
+
+ private static UpdateEmailConfigurationRequest toUpdateEmailConfigurationRequest(String id, EmailConfigurationUpdateRestRequest updateRequest) {
+ return UpdateEmailConfigurationRequest.builder()
+ .emailConfigurationId(id)
+ .host(updateRequest.getHost().toNonNullUpdatedValue())
+ .port(updateRequest.getPort().toNonNullUpdatedValue())
+ .securityProtocol(updateRequest.getSecurityProtocol().map(DefaultEmailConfigurationController::toSecurityProtocol).toNonNullUpdatedValue())
+ .fromAddress(updateRequest.getFromAddress().toNonNullUpdatedValue())
+ .fromName(updateRequest.getFromName().toNonNullUpdatedValue())
+ .subjectPrefix(updateRequest.getSubjectPrefix().toNonNullUpdatedValue())
+ .authMethod(updateRequest.getAuthMethod().map(DefaultEmailConfigurationController::toAuthMethod).toNonNullUpdatedValue())
+ .username(updateRequest.getUsername().toNonNullUpdatedValue())
+ .basicPassword(updateRequest.getBasicPassword().toNonNullUpdatedValue())
+ .oauthAuthenticationHost(updateRequest.getOauthAuthenticationHost().toNonNullUpdatedValue())
+ .oauthClientId(updateRequest.getOauthClientId().toNonNullUpdatedValue())
+ .oauthClientSecret(updateRequest.getOauthClientSecret().toNonNullUpdatedValue())
+ .oauthTenant(updateRequest.getOauthTenant().toNonNullUpdatedValue())
+ .build();
+ }
+
+ private static EmailConfigurationResource toEmailConfigurationResource(EmailConfiguration configuration) {
+ return new EmailConfigurationResource(
+ configuration.id(),
+ configuration.host(),
+ configuration.port(),
+ toRestSecurityProtocol(configuration.securityProtocol()),
+ configuration.fromAddress(),
+ configuration.fromName(),
+ configuration.subjectPrefix(),
+ toRestAuthMethod(configuration.authMethod()),
+ configuration.username(),
+ StringUtils.isNotEmpty(configuration.basicPassword()),
+ configuration.oauthAuthenticationHost(),
+ StringUtils.isNotEmpty(configuration.oauthClientId()),
+ StringUtils.isNotEmpty(configuration.oauthClientSecret()),
+ configuration.oauthTenant()
+ );
+ }
+
+ @Override
+ public void deleteEmailConfiguration(String id) {
+ userSession.checkIsSystemAdministrator();
+ emailConfigurationService.deleteConfiguration(id);
+ }
+
+ private static EmailConfigurationSecurityProtocol toSecurityProtocol(org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol restSecurityProtocol) {
+ return EmailConfigurationSecurityProtocol.valueOf(restSecurityProtocol.name());
+ }
+
+ private static EmailConfigurationAuthMethod toAuthMethod(org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod restAuthMethod) {
+ return EmailConfigurationAuthMethod.valueOf(restAuthMethod.name());
+ }
+
+ private static org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol toRestSecurityProtocol(EmailConfigurationSecurityProtocol securityProtocol) {
+ return org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol.valueOf(securityProtocol.name());
+ }
+
+ private static org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod toRestAuthMethod(EmailConfigurationAuthMethod authMethod) {
+ return org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod.valueOf(authMethod.name());
+ }
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/EmailConfigurationController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/EmailConfigurationController.java
new file mode 100644
index 00000000000..01eef1272c1
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/EmailConfigurationController.java
@@ -0,0 +1,97 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.extensions.Extension;
+import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
+import javax.validation.Valid;
+import org.sonar.server.v2.api.email.config.request.EmailConfigurationCreateRestRequest;
+import org.sonar.server.v2.api.email.config.request.EmailConfigurationUpdateRestRequest;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationResource;
+import org.sonar.server.v2.api.email.config.response.EmailConfigurationSearchRestResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.sonar.server.v2.WebApiEndpoints.EMAIL_CONFIGURATION_ENDPOINT;
+import static org.sonar.server.v2.WebApiEndpoints.INTERNAL;
+import static org.sonar.server.v2.WebApiEndpoints.JSON_MERGE_PATCH_CONTENT_TYPE;
+
+@RequestMapping(EMAIL_CONFIGURATION_ENDPOINT)
+@RestController
+public interface EmailConfigurationController {
+
+ @PostMapping
+ @Operation(summary = "Create an email configuration", description = """
+ Create a new email configuration.
+ Note that only a single configuration can exist at a time.
+ Requires 'Administer System' permission.
+ """,
+ extensions = @Extension(properties = {@ExtensionProperty(name = INTERNAL, value = "true")}))
+ EmailConfigurationResource createEmailConfiguration(@Valid @RequestBody EmailConfigurationCreateRestRequest createRequest);
+
+ @GetMapping(path = "/{id}")
+ @ResponseStatus(HttpStatus.OK)
+ @Operation(summary = "Fetch an email configuration", description = """
+ Fetch a Email configuration. Requires 'Administer System' permission.
+ """,
+ extensions = @Extension(properties = {@ExtensionProperty(name = INTERNAL, value = "true")}))
+ EmailConfigurationResource getEmailConfiguration(
+ @PathVariable("id") @Parameter(description = "The id of the configuration to fetch.", required = true, in = ParameterIn.PATH) String id);
+
+ @GetMapping
+ @Operation(summary = "Search email configurations", description = """
+ Get the list of email configurations.
+ Note that a single configuration is supported at this time.
+ Requires 'Administer System' permission.
+ """,
+ extensions = @Extension(properties = {@ExtensionProperty(name = INTERNAL, value = "true")}))
+ EmailConfigurationSearchRestResponse searchEmailConfigurations();
+
+ @PatchMapping(path = "/{id}", consumes = JSON_MERGE_PATCH_CONTENT_TYPE, produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseStatus(HttpStatus.OK)
+ @Operation(summary = "Update an email configuration", description = """
+ Update an email configuration. Requires 'Administer System' permission.
+ """,
+ extensions = @Extension(properties = {@ExtensionProperty(name = INTERNAL, value = "true")}))
+ EmailConfigurationResource updateEmailConfiguration(@PathVariable("id") String id, @Valid @RequestBody EmailConfigurationUpdateRestRequest updateRequest);
+
+ @DeleteMapping(path = "/{id}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @Operation(summary = "Delete an email configuration", description = """
+ Delete an email configuration.
+ Requires 'Administer System' permission.
+ """,
+ extensions = @Extension(properties = {@ExtensionProperty(name = INTERNAL, value = "true")}))
+ void deleteEmailConfiguration(
+ @PathVariable("id") @Parameter(description = "The id of the configuration to delete.", required = true, in = ParameterIn.PATH) String id);
+
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/package-info.java
new file mode 100644
index 00000000000..f1bf08237e6
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.v2.api.email.config.controller;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationCreateRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationCreateRestRequest.java
new file mode 100644
index 00000000000..0034d8feacb
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationCreateRestRequest.java
@@ -0,0 +1,84 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import javax.annotation.Nullable;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol;
+
+public record EmailConfigurationCreateRestRequest(
+
+ @NotEmpty
+ @Schema(description = "URL of your SMTP server")
+ String host,
+
+ @NotEmpty
+ @Schema(description = "Port of your SMTP server (usually 25, 587 or 465)")
+ String port,
+
+ @NotNull
+ @Schema(description = "Security protocol used to connect to your SMTP server (SSLTLS is recommended)")
+ EmailConfigurationSecurityProtocol securityProtocol,
+
+ @NotEmpty
+ @Schema(description = "Address emails will come from")
+ String fromAddress,
+
+ @NotEmpty
+ @Schema(description = "Name emails will come from (usually \"SonarQube\")")
+ String fromName,
+
+ @NotEmpty
+ @Schema(description = "Prefix added to email so they can be easily recognized (usually \"[SonarQube]\")")
+ String subjectPrefix,
+
+ @NotNull
+ @Schema(description = "Authentication method used to connect to the SMTP server. OAuth is only supported for Microsoft Exchange")
+ EmailConfigurationAuthMethod authMethod,
+
+ @NotEmpty
+ @Schema(description = "For Basic and OAuth authentication: username used to authenticate to the SMTP server")
+ String username,
+
+ @Nullable
+ @Schema(accessMode = Schema.AccessMode.WRITE_ONLY, description = "For basic authentication: password used to authenticate to the SMTP server")
+ String basicPassword,
+
+ @Nullable
+ @Schema(description = "For OAuth authentication: host of the Identity Provider issuing access tokens")
+ String oauthAuthenticationHost,
+
+ @Nullable
+ @Schema(accessMode = Schema.AccessMode.WRITE_ONLY, description = "For OAuth authentication: Client ID provided by Microsoft Exchange when registering the application")
+ String oauthClientId,
+
+ @Nullable
+ @Schema(accessMode = Schema.AccessMode.WRITE_ONLY, description = "For OAuth authentication: Client secret provided by Microsoft Exchange when registering the application")
+ String oauthClientSecret,
+
+ @Nullable
+ @Schema(description = "For OAuth authentication: Microsoft tenant")
+ String oauthTenant
+
+) {
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationUpdateRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationUpdateRestRequest.java
new file mode 100644
index 00000000000..8527145a440
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationUpdateRestRequest.java
@@ -0,0 +1,162 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol;
+import org.sonar.server.v2.common.model.NullOrNotEmpty;
+import org.sonar.server.v2.common.model.UpdateField;
+
+public class EmailConfigurationUpdateRestRequest {
+
+ private UpdateField<@NullOrNotEmpty String> host = UpdateField.undefined();
+ private UpdateField<@NullOrNotEmpty String> port = UpdateField.undefined();
+ private UpdateField<EmailConfigurationSecurityProtocol> securityProtocol = UpdateField.undefined();
+ private UpdateField<@NullOrNotEmpty String> fromAddress = UpdateField.undefined();
+ private UpdateField<@NullOrNotEmpty String> fromName = UpdateField.undefined();
+ private UpdateField<@NullOrNotEmpty String> subjectPrefix = UpdateField.undefined();
+ private UpdateField<EmailConfigurationAuthMethod> authMethod = UpdateField.undefined();
+ private UpdateField<@NullOrNotEmpty String> username = UpdateField.undefined();
+ private UpdateField<String> basicPassword = UpdateField.undefined();
+ private UpdateField<String> oauthAuthenticationHost = UpdateField.undefined();
+ private UpdateField<String> oauthClientId = UpdateField.undefined();
+ private UpdateField<String> oauthClientSecret = UpdateField.undefined();
+ private UpdateField<String> oauthTenant = UpdateField.undefined();
+
+ @Schema(implementation = String.class, description = "URL of your SMTP server")
+ public UpdateField<String> getHost() {
+ return host;
+ }
+
+ public void setHost(String host) {
+ this.host = UpdateField.withValue(host);
+ }
+
+ @Schema(implementation = String.class, description = "Port of your SMTP server (usually 25, 587 or 465)")
+ public UpdateField<String> getPort() {
+ return port;
+ }
+
+ public void setPort(String port) {
+ this.port = UpdateField.withValue(port);
+ }
+
+ @Schema(implementation = EmailConfigurationSecurityProtocol.class, description = "Security protocol used to connect to your SMTP server (SSLTLS is recommended)")
+ public UpdateField<EmailConfigurationSecurityProtocol> getSecurityProtocol() {
+ return securityProtocol;
+ }
+
+ public void setSecurityProtocol(EmailConfigurationSecurityProtocol securityProtocol) {
+ this.securityProtocol = UpdateField.withValue(securityProtocol);
+ }
+
+ @Schema(implementation = String.class, description = "Address emails will come from")
+ public UpdateField<String> getFromAddress() {
+ return fromAddress;
+ }
+
+ public void setFromAddress(String fromAddress) {
+ this.fromAddress = UpdateField.withValue(fromAddress);
+ }
+
+ @Schema(implementation = String.class, description = "Name emails will come from (usually \"SonarQube\")")
+ public UpdateField<String> getFromName() {
+ return fromName;
+ }
+
+ public void setFromName(String fromName) {
+ this.fromName = UpdateField.withValue(fromName);
+ }
+
+ @Schema(implementation = String.class, description = "Prefix added to email so they can be easily recognized (usually \"[SonarQube]\")")
+ public UpdateField<String> getSubjectPrefix() {
+ return subjectPrefix;
+ }
+
+ public void setSubjectPrefix(String subjectPrefix) {
+ this.subjectPrefix = UpdateField.withValue(subjectPrefix);
+ }
+
+ @Schema(implementation = EmailConfigurationAuthMethod.class,
+ description = "Authentication method used to connect to the SMTP server. OAuth is only supported for Microsoft Exchange")
+ public UpdateField<EmailConfigurationAuthMethod> getAuthMethod() {
+ return authMethod;
+ }
+
+ public void setAuthMethod(EmailConfigurationAuthMethod authMethod) {
+ this.authMethod = UpdateField.withValue(authMethod);
+ }
+
+ @Schema(implementation = String.class, description = "For Basic and OAuth authentication: username used to authenticate to the SMTP server")
+ public UpdateField<String> getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = UpdateField.withValue(username);
+ }
+
+ @Schema(implementation = String.class, description = "For basic authentication: password used to authenticate to the SMTP server")
+ public UpdateField<String> getBasicPassword() {
+ return basicPassword;
+ }
+
+ public void setBasicPassword(String basicPassword) {
+ this.basicPassword = UpdateField.withValue(basicPassword);
+ }
+
+ @Schema(implementation = String.class, description = "For OAuth authentication: host of the Identity Provider issuing access tokens")
+ public UpdateField<String> getOauthAuthenticationHost() {
+ return oauthAuthenticationHost;
+ }
+
+ public void setOauthAuthenticationHost(String oauthAuthenticationHost) {
+ this.oauthAuthenticationHost = UpdateField.withValue(oauthAuthenticationHost);
+ }
+
+ @Schema(implementation = String.class, description = "For OAuth authentication: Client ID provided by Microsoft Exchange when registering the application")
+ public UpdateField<String> getOauthClientId() {
+ return oauthClientId;
+ }
+
+ public void setOauthClientId(String oauthClientId) {
+ this.oauthClientId = UpdateField.withValue(oauthClientId);
+ }
+
+ @Schema(implementation = String.class, description = "For OAuth authentication: Client password provided by Microsoft Exchange when registering the application")
+ public UpdateField<String> getOauthClientSecret() {
+ return oauthClientSecret;
+ }
+
+ public void setOauthClientSecret(String oauthClientSecret) {
+ this.oauthClientSecret = UpdateField.withValue(oauthClientSecret);
+ }
+
+ @Schema(implementation = String.class, description = "For OAuth authentication: Microsoft tenant")
+ public UpdateField<String> getOauthTenant() {
+ return oauthTenant;
+ }
+
+ public void setOauthTenant(String oauthTenant) {
+ this.oauthTenant = UpdateField.withValue(oauthTenant);
+ }
+
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/package-info.java
new file mode 100644
index 00000000000..26b888985dd
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.v2.api.email.config.request;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationAuthMethod.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationAuthMethod.java
new file mode 100644
index 00000000000..5d9e14c5ab7
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationAuthMethod.java
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.resource;
+
+public enum EmailConfigurationAuthMethod {
+ BASIC, OAUTH
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationResource.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationResource.java
new file mode 100644
index 00000000000..ca2a6db0638
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationResource.java
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.resource;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import javax.annotation.Nullable;
+
+public record EmailConfigurationResource(
+
+ @Schema(accessMode = Schema.AccessMode.READ_ONLY)
+ String id,
+
+ @Schema(description = "URL of your SMTP server")
+ String host,
+
+ @Schema(description = "Port of your SMTP server (usually 25, 587 or 465)")
+ String port,
+
+ @Schema(description = "Security protocol used to connect to your SMTP server (SSLTLS is recommended)")
+ EmailConfigurationSecurityProtocol securityProtocol,
+
+ @Schema(description = "Address emails will come from")
+ String fromAddress,
+
+ @Schema(description = "Name emails will come from (usually \"SonarQube\")")
+ String fromName,
+
+ @Schema(description = "Prefix added to email so they can be easily recognized (usually \"[SonarQube]\")")
+ String subjectPrefix,
+
+ @Schema(description = "Authentication method used to connect to the SMTP server. OAuth is only supported for Microsoft Exchange")
+ EmailConfigurationAuthMethod authMethod,
+
+ @Nullable
+ @Schema(description = "For Basic and OAuth authentication: username used to authenticate to the SMTP server")
+ String username,
+
+ @Schema(description = "For Basic authentication: has the password field been set?")
+ boolean isBasicPasswordSet,
+
+ @Nullable
+ @Schema(description = "For OAuth authentication: host of the Identity Provider issuing access tokens")
+ String oauthAuthenticationHost,
+
+ @Schema(description = "For OAuth authentication: has the Client ID field been set?")
+ boolean isOauthClientIdSet,
+
+ @Schema(description = "For OAuth authentication: has the Client secret field been set?")
+ boolean isOauthClientSecretSet,
+
+ @Nullable
+ @Schema(description = "For OAuth authentication: Microsoft tenant")
+ String oauthTenant
+
+) {
+}
+
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationSecurityProtocol.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationSecurityProtocol.java
new file mode 100644
index 00000000000..3852ddef59a
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationSecurityProtocol.java
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.resource;
+
+public enum EmailConfigurationSecurityProtocol {
+ NONE, SSLTLS, STARTTLS
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/package-info.java
new file mode 100644
index 00000000000..6800ffa2b84
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.v2.api.email.config.resource;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/EmailConfigurationSearchRestResponse.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/EmailConfigurationSearchRestResponse.java
new file mode 100644
index 00000000000..c17f4785f4f
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/EmailConfigurationSearchRestResponse.java
@@ -0,0 +1,27 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config.response;
+
+import java.util.List;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationResource;
+import org.sonar.server.v2.api.response.PageRestResponse;
+
+public record EmailConfigurationSearchRestResponse(List<EmailConfigurationResource> emailConfigurations, PageRestResponse page) {}
+
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/package-info.java
new file mode 100644
index 00000000000..4aebb920e55
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.v2.api.email.config.response;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmpty.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmpty.java
new file mode 100644
index 00000000000..7f69d3cad44
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmpty.java
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.common.model;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import javax.validation.Constraint;
+import javax.validation.Payload;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE_USE;
+
+@Documented
+@Constraint(validatedBy = NullOrNotEmptyValidator.class)
+@Target( {METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface NullOrNotEmpty {
+ String message() default "must not be empty";
+ Class<?>[] groups() default {};
+ Class<? extends Payload>[] payload() default {};
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidator.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidator.java
new file mode 100644
index 00000000000..b6a40c694d0
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidator.java
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.common.model;
+
+import javax.annotation.Nullable;
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+public class NullOrNotEmptyValidator implements ConstraintValidator<NullOrNotEmpty, String> {
+ @Override
+ public boolean isValid(@Nullable String value, ConstraintValidatorContext context) {
+ return value == null || !value.isEmpty();
+ }
+}
diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
index 7394832467b..87ef131bb29 100644
--- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
+++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
@@ -24,6 +24,7 @@ import org.sonar.api.platform.Server;
import org.sonar.api.resources.Languages;
import org.sonar.db.Database;
import org.sonar.db.DbClient;
+import org.sonar.server.common.email.config.EmailConfigurationService;
import org.sonar.server.common.github.config.GithubConfigurationService;
import org.sonar.server.common.gitlab.config.GitlabConfigurationService;
import org.sonar.server.common.group.service.GroupMembershipService;
@@ -60,6 +61,8 @@ import org.sonar.server.v2.api.analysis.service.ScannerEngineHandler;
import org.sonar.server.v2.api.analysis.service.ScannerEngineHandlerImpl;
import org.sonar.server.v2.api.dop.controller.DefaultDopSettingsController;
import org.sonar.server.v2.api.dop.controller.DopSettingsController;
+import org.sonar.server.v2.api.email.config.controller.DefaultEmailConfigurationController;
+import org.sonar.server.v2.api.email.config.controller.EmailConfigurationController;
import org.sonar.server.v2.api.github.config.controller.DefaultGithubConfigurationController;
import org.sonar.server.v2.api.github.config.controller.GithubConfigurationController;
import org.sonar.server.v2.api.gitlab.config.controller.DefaultGitlabConfigurationController;
@@ -206,4 +209,9 @@ public class PlatformLevel4WebConfig {
return new DefaultScannerEngineController(scannerEngineHandler);
}
+ @Bean
+ public EmailConfigurationController emailConfigurationController(UserSession userSession, EmailConfigurationService emailConfigurationService) {
+ return new DefaultEmailConfigurationController(userSession, emailConfigurationService);
+ }
+
}
diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/email/config/DefaultEmailConfigurationControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/email/config/DefaultEmailConfigurationControllerTest.java
new file mode 100644
index 00000000000..c2e0b811da6
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/email/config/DefaultEmailConfigurationControllerTest.java
@@ -0,0 +1,597 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.api.email.config;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.sonar.server.common.NonNullUpdatedValue;
+import org.sonar.server.common.email.config.EmailConfiguration;
+import org.sonar.server.common.email.config.EmailConfigurationAuthMethod;
+import org.sonar.server.common.email.config.EmailConfigurationSecurityProtocol;
+import org.sonar.server.common.email.config.EmailConfigurationService;
+import org.sonar.server.common.email.config.UpdateEmailConfigurationRequest;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.v2.api.ControllerTester;
+import org.sonar.server.v2.api.email.config.controller.DefaultEmailConfigurationController;
+import org.sonar.server.v2.api.email.config.resource.EmailConfigurationResource;
+import org.sonar.server.v2.api.email.config.response.EmailConfigurationSearchRestResponse;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.sonar.server.common.email.config.EmailConfigurationService.UNIQUE_EMAIL_CONFIGURATION_ID;
+import static org.sonar.server.v2.WebApiEndpoints.EMAIL_CONFIGURATION_ENDPOINT;
+import static org.sonar.server.v2.WebApiEndpoints.JSON_MERGE_PATCH_CONTENT_TYPE;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class DefaultEmailConfigurationControllerTest {
+
+ private static final Gson GSON = new GsonBuilder().create();
+
+ private static final EmailConfiguration EMAIL_BASIC_CONFIGURATION = new EmailConfiguration(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ "host",
+ "port",
+ EmailConfigurationSecurityProtocol.SSLTLS,
+ "fromAddress",
+ "fromName",
+ "subjectPrefix",
+ EmailConfigurationAuthMethod.BASIC,
+ "username",
+ "basicPassword",
+ null,
+ null,
+ null,
+ null
+ );
+
+ private static final EmailConfiguration EMAIL_OAUTH_CONFIGURATION = new EmailConfiguration(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ "host",
+ "port",
+ EmailConfigurationSecurityProtocol.STARTTLS,
+ "fromAddress",
+ "fromName",
+ "subjectPrefix",
+ EmailConfigurationAuthMethod.OAUTH,
+ "username",
+ null,
+ "oauthAuthenticationHost",
+ "oauthClientId",
+ "oauthClientSecret",
+ "oauthTenant"
+ );
+
+ private static final String EXPECTED_BASIC_CONFIGURATION = """
+ {
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "SSLTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "BASIC",
+ "username": "username",
+ "isBasicPasswordSet": true
+ }
+ """;
+
+ private static final String EXPECTED_OAUTH_CONFIGURATION = """
+ {
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "STARTTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "OAUTH",
+ "username": "username",
+ "isBasicPasswordSet": false,
+ "oauthAuthenticationHost": "oauthAuthenticationHost",
+ "isOauthClientIdSet": true,
+ "isOauthClientSecretSet": true,
+ "oauthTenant": "oauthTenant"
+ }
+ """;
+
+ @RegisterExtension
+ public UserSessionRule userSession = UserSessionRule.standalone();
+ private final EmailConfigurationService emailConfigurationService = mock();
+ private final MockMvc mockMvc = ControllerTester.getMockMvc(new DefaultEmailConfigurationController(userSession, emailConfigurationService));
+
+ @Test
+ void createEmailConfiguration_whenUserIsNotAdmin_shouldReturnForbidden() throws Exception {
+ userSession.logIn().setNonSystemAdministrator();
+
+ mockMvc.perform(
+ post(EMAIL_CONFIGURATION_ENDPOINT)
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("""
+ {
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "NONE",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "BASIC",
+ "username": "username",
+ "basicPassword": "basicPassword"
+ }
+ """))
+ .andExpectAll(
+ status().isForbidden(),
+ content().json("{\"message\":\"Insufficient privileges\"}"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"host", "port", "fromAddress", "fromName", "subjectPrefix", "username"})
+ void create_whenRequiredFieldEmpty_shouldReturnBadRequest(String field) throws Exception {
+ userSession.logIn().setSystemAdministrator();
+
+ String payload = format("""
+ {
+ "host": "%s",
+ "port": "%s",
+ "securityProtocol": "NONE",
+ "fromAddress": "%s",
+ "fromName": "%s",
+ "subjectPrefix": "%s",
+ "authMethod": "BASIC",
+ "username": "%s"
+ }
+ """,
+ field.equals("host") ? "" : "host",
+ field.equals("port") ? "" : "port",
+ field.equals("fromAddress") ? "" : "fromAddress",
+ field.equals("fromName") ? "" : "fromName",
+ field.equals("subjectPrefix") ? "" : "subjectPrefix",
+ field.equals("username") ? "" : "username");
+
+ mockMvc.perform(post(EMAIL_CONFIGURATION_ENDPOINT)
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(payload))
+ .andExpectAll(
+ status().isBadRequest(),
+ content().json(format("{\"message\":\"Value for field %s was rejected. Error: must not be empty.\"}", field)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"host", "port", "fromAddress", "fromName", "subjectPrefix", "username"})
+ void create_whenRequiredStringFieldNull_shouldReturnBadRequest(String field) throws Exception {
+ userSession.logIn().setSystemAdministrator();
+
+ String payload = format("""
+ {
+ %s
+ %s
+ %s
+ %s
+ %s
+ %s
+ "securityProtocol": "NONE",
+ "authMethod": "BASIC"
+ }
+ """,
+ field.equals("host") ? "" : "\"host\" : \"host\",",
+ field.equals("port") ? "" : "\"port\" : \"port\",",
+ field.equals("fromAddress") ? "" : "\"fromAddress\" : \"fromAddress\",",
+ field.equals("fromName") ? "" : "\"fromName\" : \"fromName\",",
+ field.equals("subjectPrefix") ? "" : "\"subjectPrefix\" : \"subjectPrefix\",",
+ field.equals("username") ? "" : "\"username\" : \"username\",");
+
+ mockMvc.perform(post(EMAIL_CONFIGURATION_ENDPOINT)
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(payload))
+ .andExpectAll(
+ status().isBadRequest(),
+ content().json(format("{\"message\":\"Value {} for field %s was rejected. Error: must not be empty.\"}", field)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"securityProtocol", "authMethod"})
+ void create_whenRequiredEnumFieldNull_shouldReturnBadRequest(String field) throws Exception {
+ userSession.logIn().setSystemAdministrator();
+
+ String payload = format("""
+ {
+ %s
+ %s
+ "host": "host",
+ "port": "port",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "username": "username"
+ }
+ """,
+ field.equals("securityProtocol") ? "" : "\"securityProtocol\" : \"NONE\",",
+ field.equals("authMethod") ? "" : "\"authMethod\" : \"BASIC\",");
+
+ mockMvc.perform(post(EMAIL_CONFIGURATION_ENDPOINT)
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(payload))
+ .andExpectAll(
+ status().isBadRequest(),
+ content().json(format("{\"message\":\"Value {} for field %s was rejected. Error: must not be null.\"}", field)));
+ }
+
+ @Test
+ void create_whenBasicConfigCreated_returnsItWithoutSecrets() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.createConfiguration(any())).thenReturn(EMAIL_BASIC_CONFIGURATION);
+
+ mockMvc.perform(
+ post(EMAIL_CONFIGURATION_ENDPOINT)
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("""
+ {
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "SSLTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "BASIC",
+ "username": "username",
+ "basicPassword": "basicPassword"
+ }
+ """))
+ .andExpectAll(
+ status().isOk(),
+ content().json("""
+ {
+ "id": "email-configuration",
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "SSLTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "BASIC",
+ "username": "username",
+ "isBasicPasswordSet": true
+ }
+ """));
+
+ verify(emailConfigurationService).createConfiguration(any());
+ }
+
+ @Test
+ void create_whenOauthConfigCreated_returnsItWithoutSecrets() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.createConfiguration(any())).thenReturn(EMAIL_OAUTH_CONFIGURATION);
+
+ mockMvc.perform(
+ post(EMAIL_CONFIGURATION_ENDPOINT)
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content("""
+ {
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "STARTTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "OAUTH",
+ "username": "username",
+ "oauthAuthenticationHost": "oauthAuthenticationHost",
+ "oauthClientId": "oauthClientId",
+ "oauthClientSecret": "oauthClientSecret",
+ "oauthTenant": "oauthTenant"
+ }
+ """))
+ .andExpectAll(
+ status().isOk(),
+ content().json("""
+ {
+ "id": "email-configuration",
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "STARTTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "OAUTH",
+ "username": "username",
+ "isBasicPasswordSet": false,
+ "oauthAuthenticationHost": "oauthAuthenticationHost",
+ "isOauthClientIdSet": true,
+ "isOauthClientSecretSet": true,
+ "oauthTenant": "oauthTenant"
+ }
+ """));
+
+ verify(emailConfigurationService).createConfiguration(any());
+ }
+
+ @Test
+ void getEmailConfiguration_whenUserIsNotAdmin_shouldReturnForbidden() throws Exception {
+ userSession.logIn().setNonSystemAdministrator();
+
+ mockMvc.perform(get(EMAIL_CONFIGURATION_ENDPOINT + "/whatever-id"))
+ .andExpectAll(
+ status().isForbidden(),
+ content().json("{\"message\":\"Insufficient privileges\"}"));
+ }
+
+ @Test
+ void getEmailConfiguration_whenConfigNotFound_throws() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.getConfiguration("not-existing-id")).thenThrow(new NotFoundException("Not found"));
+
+ mockMvc.perform(get(EMAIL_CONFIGURATION_ENDPOINT + "/not-existing-id"))
+ .andExpectAll(
+ status().isNotFound(),
+ content().json("{\"message\":\"Not found\"}"));
+
+ verify(emailConfigurationService).getConfiguration("not-existing-id");
+ }
+
+ @Test
+ void getEmailConfiguration_whenConfigFound_returnsIt() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.getConfiguration("existing-id")).thenReturn(EMAIL_BASIC_CONFIGURATION);
+
+ mockMvc.perform(get(EMAIL_CONFIGURATION_ENDPOINT + "/existing-id"))
+ .andExpectAll(
+ status().isOk(),
+ content().json(EXPECTED_BASIC_CONFIGURATION));
+
+ verify(emailConfigurationService).getConfiguration("existing-id");
+ }
+
+ @Test
+ void searchEmailConfigurations_whenUserIsNotAdmin_shouldReturnForbidden() throws Exception {
+ userSession.logIn().setNonSystemAdministrator();
+
+ mockMvc.perform(get(EMAIL_CONFIGURATION_ENDPOINT + "/whatever-id"))
+ .andExpectAll(
+ status().isForbidden(),
+ content().json("{\"message\":\"Insufficient privileges\"}"));
+ }
+
+ @Test
+ void searchEmailConfigurations_whenNoParams_shouldReturnDefault() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.findConfigurations()).thenReturn(Optional.of(EMAIL_BASIC_CONFIGURATION));
+
+ MvcResult mvcResult = mockMvc.perform(get(EMAIL_CONFIGURATION_ENDPOINT))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ EmailConfigurationSearchRestResponse response = GSON.fromJson(mvcResult.getResponse().getContentAsString(), EmailConfigurationSearchRestResponse.class);
+
+ assertThat(response.page().pageSize()).isEqualTo(1000);
+ assertThat(response.page().pageIndex()).isEqualTo(1);
+ assertThat(response.page().total()).isEqualTo(1);
+ assertThat(response.emailConfigurations()).containsExactly(toEmailConfigurationResource(EMAIL_BASIC_CONFIGURATION));
+ verify(emailConfigurationService).findConfigurations();
+ }
+
+ private EmailConfigurationResource toEmailConfigurationResource(EmailConfiguration emailConfiguration) {
+ return new EmailConfigurationResource(
+ emailConfiguration.id(),
+ emailConfiguration.host(),
+ emailConfiguration.port(),
+ toRestSecurityProtocol(emailConfiguration.securityProtocol()),
+ emailConfiguration.fromAddress(),
+ emailConfiguration.fromName(),
+ emailConfiguration.subjectPrefix(),
+ toRestAuthMethod(emailConfiguration.authMethod()),
+ emailConfiguration.username(),
+ emailConfiguration.basicPassword() != null,
+ emailConfiguration.oauthAuthenticationHost(),
+ emailConfiguration.oauthClientId() != null,
+ emailConfiguration.oauthClientSecret() != null,
+ emailConfiguration.oauthTenant()
+ );
+ }
+
+ private static org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol toRestSecurityProtocol(EmailConfigurationSecurityProtocol securityProtocol) {
+ return org.sonar.server.v2.api.email.config.resource.EmailConfigurationSecurityProtocol.valueOf(securityProtocol.name());
+ }
+
+ private static org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod toRestAuthMethod(EmailConfigurationAuthMethod authMethod) {
+ return org.sonar.server.v2.api.email.config.resource.EmailConfigurationAuthMethod.valueOf(authMethod.name());
+ }
+
+ @Test
+ void searchEmailConfigurations_whenNoParamAndNoConfig_shouldReturnEmptyList() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.findConfigurations()).thenReturn(Optional.empty());
+
+ MvcResult mvcResult = mockMvc.perform(get(EMAIL_CONFIGURATION_ENDPOINT))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ EmailConfigurationSearchRestResponse response = GSON.fromJson(mvcResult.getResponse().getContentAsString(), EmailConfigurationSearchRestResponse.class);
+
+ assertThat(response.page().pageSize()).isEqualTo(1000);
+ assertThat(response.page().pageIndex()).isEqualTo(1);
+ assertThat(response.page().total()).isZero();
+ assertThat(response.emailConfigurations()).isEmpty();
+ verify(emailConfigurationService).findConfigurations();
+ }
+
+ @Test
+ void updateConfiguration_whenUserIsNotAdmin_shouldReturnForbidden() throws Exception {
+ userSession.logIn().setNonSystemAdministrator();
+
+ mockMvc.perform(patch(EMAIL_CONFIGURATION_ENDPOINT + "/whatever-id")
+ .contentType(JSON_MERGE_PATCH_CONTENT_TYPE)
+ .content("{}"))
+ .andExpectAll(
+ status().isForbidden(),
+ content().json("{\"message\":\"Insufficient privileges\"}"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"host", "port", "fromAddress", "fromName", "subjectPrefix", "username"})
+ void update_whenRequiredFieldEmpty_shouldReturnBadRequest(String field) throws Exception {
+ userSession.logIn().setSystemAdministrator();
+
+ mockMvc.perform(patch(EMAIL_CONFIGURATION_ENDPOINT + "/" + UNIQUE_EMAIL_CONFIGURATION_ID)
+ .contentType(JSON_MERGE_PATCH_CONTENT_TYPE)
+ .content(format("""
+ {
+ "%s": ""
+ }
+ """, field)))
+ .andExpectAll(
+ status().isBadRequest(),
+ content().json(format("{\"message\":\"Value for field %s was rejected. Error: must not be empty.\"}", field)));
+ }
+
+ @Test
+ void updateConfiguration_whenAllFieldsUpdated_performUpdates() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.updateConfiguration(any())).thenReturn(EMAIL_OAUTH_CONFIGURATION);
+
+ mockMvc.perform(patch(EMAIL_CONFIGURATION_ENDPOINT + "/" + UNIQUE_EMAIL_CONFIGURATION_ID)
+ .contentType(JSON_MERGE_PATCH_CONTENT_TYPE)
+ .content("""
+ {
+ "host": "host",
+ "port": "port",
+ "securityProtocol": "STARTTLS",
+ "fromAddress": "fromAddress",
+ "fromName": "fromName",
+ "subjectPrefix": "subjectPrefix",
+ "authMethod": "OAUTH",
+ "username": "username",
+ "basicPassword": "basicPassword",
+ "oauthAuthenticationHost": "oauthAuthenticationHost",
+ "oauthClientId": "oauthClientId",
+ "oauthClientSecret": "oauthClientSecret",
+ "oauthTenant": "oauthTenant"
+ }
+ """))
+ .andExpectAll(
+ status().isOk(),
+ content().json(EXPECTED_OAUTH_CONFIGURATION));
+
+ verify(emailConfigurationService).updateConfiguration(new UpdateEmailConfigurationRequest(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ NonNullUpdatedValue.withValueOrThrow("host"),
+ NonNullUpdatedValue.withValueOrThrow("port"),
+ NonNullUpdatedValue.withValueOrThrow(EmailConfigurationSecurityProtocol.STARTTLS),
+ NonNullUpdatedValue.withValueOrThrow("fromAddress"),
+ NonNullUpdatedValue.withValueOrThrow("fromName"),
+ NonNullUpdatedValue.withValueOrThrow("subjectPrefix"),
+ NonNullUpdatedValue.withValueOrThrow(EmailConfigurationAuthMethod.OAUTH),
+ NonNullUpdatedValue.withValueOrThrow("username"),
+ NonNullUpdatedValue.withValueOrThrow("basicPassword"),
+ NonNullUpdatedValue.withValueOrThrow("oauthAuthenticationHost"),
+ NonNullUpdatedValue.withValueOrThrow("oauthClientId"),
+ NonNullUpdatedValue.withValueOrThrow("oauthClientSecret"),
+ NonNullUpdatedValue.withValueOrThrow("oauthTenant")
+ ));
+ }
+
+ @Test
+ void updateConfiguration_whenSomeFieldsUpdated_performUpdates() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ when(emailConfigurationService.updateConfiguration(any())).thenReturn(EMAIL_OAUTH_CONFIGURATION);
+
+ mockMvc.perform(patch(EMAIL_CONFIGURATION_ENDPOINT + "/" + UNIQUE_EMAIL_CONFIGURATION_ID)
+ .contentType(JSON_MERGE_PATCH_CONTENT_TYPE)
+ .content("""
+ {
+ "host": "host",
+ "oauthTenant": "oauthTenant"
+ }
+ """))
+ .andExpectAll(
+ status().isOk(),
+ content().json(EXPECTED_OAUTH_CONFIGURATION));
+
+ verify(emailConfigurationService).updateConfiguration(new UpdateEmailConfigurationRequest(
+ UNIQUE_EMAIL_CONFIGURATION_ID,
+ NonNullUpdatedValue.withValueOrThrow("host"),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.undefined(),
+ NonNullUpdatedValue.withValueOrThrow("oauthTenant")
+ ));
+ }
+
+ @Test
+ void delete_whenUserIsNotAdmin_shouldReturnForbidden() throws Exception {
+ userSession.logIn().setNonSystemAdministrator();
+
+ mockMvc.perform(
+ delete(EMAIL_CONFIGURATION_ENDPOINT + "/" + UNIQUE_EMAIL_CONFIGURATION_ID))
+ .andExpectAll(
+ status().isForbidden(),
+ content().json("{\"message\":\"Insufficient privileges\"}"));
+ }
+
+ @Test
+ void delete_whenConfigIsDeleted_returnsNoContent() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+
+ mockMvc.perform(
+ delete(EMAIL_CONFIGURATION_ENDPOINT + "/" + UNIQUE_EMAIL_CONFIGURATION_ID))
+ .andExpectAll(
+ status().isNoContent());
+
+ verify(emailConfigurationService).deleteConfiguration(UNIQUE_EMAIL_CONFIGURATION_ID);
+ }
+
+ @Test
+ void delete_whenConfigNotFound_returnsNotFound() throws Exception {
+ userSession.logIn().setSystemAdministrator();
+ doThrow(new NotFoundException("Not found")).when(emailConfigurationService).deleteConfiguration("not-existing-id");
+
+ mockMvc.perform(
+ delete(EMAIL_CONFIGURATION_ENDPOINT + "/not-existing-id"))
+ .andExpectAll(
+ status().isNotFound(),
+ content().json("{\"message\":\"Not found\"}"));
+ }
+
+}
diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidatorTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidatorTest.java
new file mode 100644
index 00000000000..2b9799bda63
--- /dev/null
+++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidatorTest.java
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.v2.common.model;
+
+import javax.validation.ConstraintValidatorContext;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+class NullOrNotEmptyValidatorTest {
+
+ NullOrNotEmptyValidator validator = new NullOrNotEmptyValidator();
+ ConstraintValidatorContext context = mock();
+
+ @Test
+ void isValid_shouldValidateNull() {
+ assertTrue(validator.isValid(null, context));
+ }
+
+ @Test
+ void isValid_shouldValidateNotEmptyString() {
+ assertTrue(validator.isValid("not-empty", context));
+ }
+
+ @Test
+ void isValid_shouldNotValidateEmptyString() {
+ assertFalse(validator.isValid("", context));
+ }
+
+} \ No newline at end of file
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/email/ws/EmailsWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/email/ws/EmailsWsModule.java
index 6c54426c691..ad5226988ff 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/email/ws/EmailsWsModule.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/email/ws/EmailsWsModule.java
@@ -20,11 +20,13 @@
package org.sonar.server.email.ws;
import org.sonar.core.platform.Module;
+import org.sonar.server.common.email.config.EmailConfigurationService;
public class EmailsWsModule extends Module {
@Override
protected void configureModule() {
add(
+ EmailConfigurationService.class,
EmailsWs.class,
SendAction.class);
}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/email/ws/EmailsWsModuleTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/email/ws/EmailsWsModuleTest.java
index 8e076cb9c20..e5775c25f9f 100644
--- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/email/ws/EmailsWsModuleTest.java
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/email/ws/EmailsWsModuleTest.java
@@ -29,6 +29,6 @@ public class EmailsWsModuleTest {
public void verify_count_of_added_components() {
ListContainer container = new ListContainer();
new EmailsWsModule().configure(container);
- assertThat(container.getAddedObjects()).hasSize(2);
+ assertThat(container.getAddedObjects()).hasSize(3);
}
}
diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/emails/EmailConfigurationService.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/emails/EmailConfigurationService.java
new file mode 100644
index 00000000000..785d61b88e4
--- /dev/null
+++ b/sonar-ws/src/main/java/org/sonarqube/ws/client/emails/EmailConfigurationService.java
@@ -0,0 +1,91 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonarqube.ws.client.emails;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import org.sonarqube.ws.client.BaseService;
+import org.sonarqube.ws.client.DeleteRequest;
+import org.sonarqube.ws.client.HttpException;
+import org.sonarqube.ws.client.PostRequest;
+import org.sonarqube.ws.client.WsConnector;
+import org.sonarqube.ws.client.WsResponse;
+
+import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
+
+public class EmailConfigurationService extends BaseService {
+
+
+ private static final String EMAIL_CONFIGURATION_ID = "email-configuration";
+
+ public EmailConfigurationService(WsConnector wsConnector) {
+ super(wsConnector, "api/v2/system/email-configurations");
+ }
+
+ public String createEmailConfiguration(WsEmailConfiguration config) {
+ String body = String.format("""
+ {
+ "host": "%s",
+ "port": "%s",
+ "securityProtocol": "%s",
+ "fromAddress": "%s",
+ "fromName": "%s",
+ "subjectPrefix": "%s",
+ "authMethod": "%s",
+ "username": "%s",
+ "basicPassword": "%s",
+ "oauthAuthenticationHost": "%s",
+ "oauthClientId": "%s",
+ "oauthClientSecret": "%s",
+ "oauthTenant": "%s"
+ }
+ """,
+ config.host(),
+ config.port(),
+ config.securityProtocol(),
+ config.fromAddress(),
+ config.fromName(),
+ config.subjectPrefix(),
+ config.authMethod(),
+ config.username(),
+ config.basicPassword(),
+ config.oauthAuthenticationHost(),
+ config.oauthClientId(),
+ config.oauthClientSecret(),
+ config.oauthTenant()
+ );
+
+ WsResponse response = call(
+ new PostRequest(path()).setBody(body)
+ );
+ return new Gson().fromJson(response.content(), JsonObject.class).get("id").getAsString();
+ }
+
+ public void deleteEmailConfiguration() {
+ try {
+ call(new DeleteRequest(path() + "/" + EMAIL_CONFIGURATION_ID));
+ } catch (HttpException e) {
+ // We ignore if it gets deleted while there is no configuration
+ if (e.code() != HTTP_NOT_FOUND) {
+ throw e;
+ }
+ }
+ }
+}
diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/emails/WsEmailConfiguration.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/emails/WsEmailConfiguration.java
new file mode 100644
index 00000000000..a44f47edc3b
--- /dev/null
+++ b/sonar-ws/src/main/java/org/sonarqube/ws/client/emails/WsEmailConfiguration.java
@@ -0,0 +1,129 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonarqube.ws.client.emails;
+
+public record WsEmailConfiguration(
+ String host,
+ String port,
+ String securityProtocol,
+ String fromAddress,
+ String fromName,
+ String subjectPrefix,
+ String authMethod,
+ String username,
+ String basicPassword,
+ String oauthAuthenticationHost,
+ String oauthClientId,
+ String oauthClientSecret,
+ String oauthTenant
+ ) {
+
+ public static WsEmailConfigurationBuilder builder() {
+ return new WsEmailConfigurationBuilder();
+ }
+
+ public static final class WsEmailConfigurationBuilder {
+ private String host;
+ private String port;
+ private String securityProtocol;
+ private String fromAddress;
+ private String fromName;
+ private String subjectPrefix;
+ private String authMethod;
+ private String username;
+ private String basicPassword;
+ private String oauthAuthenticationHost;
+ private String oauthClientId;
+ private String oauthClientSecret;
+ private String oauthTenant;
+
+ private WsEmailConfigurationBuilder() {
+ }
+
+ public WsEmailConfigurationBuilder host(String host) {
+ this.host = host;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder port(String port) {
+ this.port = port;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder securityProtocol(String securityProtocol) {
+ this.securityProtocol = securityProtocol;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder fromAddress(String fromAddress) {
+ this.fromAddress = fromAddress;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder fromName(String fromName) {
+ this.fromName = fromName;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder subjectPrefix(String subjectPrefix) {
+ this.subjectPrefix = subjectPrefix;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder authMethod(String authMethod) {
+ this.authMethod = authMethod;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder username(String username) {
+ this.username = username;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder basicPassword(String basicPassword) {
+ this.basicPassword = basicPassword;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder oauthAuthenticationHost(String oauthAuthenticationHost) {
+ this.oauthAuthenticationHost = oauthAuthenticationHost;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder oauthClientId(String oauthClientId) {
+ this.oauthClientId = oauthClientId;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder oauthClientSecret(String oauthClientSecret) {
+ this.oauthClientSecret = oauthClientSecret;
+ return this;
+ }
+
+ public WsEmailConfigurationBuilder oauthTenant(String oauthTenant) {
+ this.oauthTenant = oauthTenant;
+ return this;
+ }
+
+ public WsEmailConfiguration build() {
+ return new WsEmailConfiguration(host, port, securityProtocol, fromAddress, fromName, subjectPrefix, authMethod, username, basicPassword, oauthAuthenticationHost, oauthClientId, oauthClientSecret, oauthTenant);
+ }
+ }
+}