]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22516 API v2 endpoints for managing the email configuration
authorAntoine Vigneau <antoine.vigneau@sonarsource.com>
Tue, 6 Aug 2024 11:45:14 +0000 (13:45 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 16 Aug 2024 20:02:58 +0000 (20:02 +0000)
32 files changed:
server/sonar-server-common/src/main/java/org/sonar/server/email/EmailSmtpConfiguration.java
server/sonar-server-common/src/main/java/org/sonar/server/notification/email/telemetry/package-info.java [new file with mode: 0644]
server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationBuilder.java [new file with mode: 0644]
server/sonar-webserver-common/src/it/java/org/sonar/server/common/email/config/EmailConfigurationServiceIT.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfiguration.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationAuthMethod.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationSecurityProtocol.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/EmailConfigurationService.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/UpdateEmailConfigurationRequest.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/email/config/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/DefaultEmailConfigurationController.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/EmailConfigurationController.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/controller/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationCreateRestRequest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/EmailConfigurationUpdateRestRequest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/request/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationAuthMethod.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationResource.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/EmailConfigurationSecurityProtocol.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/resource/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/EmailConfigurationSearchRestResponse.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/email/config/response/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmpty.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidator.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/email/config/DefaultEmailConfigurationControllerTest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/common/model/NullOrNotEmptyValidatorTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/email/ws/EmailsWsModule.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/email/ws/EmailsWsModuleTest.java
sonar-ws/src/main/java/org/sonarqube/ws/client/emails/EmailConfigurationService.java [new file with mode: 0644]
sonar-ws/src/main/java/org/sonarqube/ws/client/emails/WsEmailConfiguration.java [new file with mode: 0644]

index b7873fab9438c349c2470174e872c01f5c048a9c..f8ab524b7e22069160d6e0e7466e281a4fe09e46 100644 (file)
@@ -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 (file)
index 0000000..0722ecb
--- /dev/null
@@ -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 (file)
index 0000000..ae61964
--- /dev/null
@@ -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 (file)
index 0000000..2ebb26e
--- /dev/null
@@ -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 (file)
index 0000000..8b5a2d1
--- /dev/null
@@ -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 (file)
index 0000000..2269794
--- /dev/null
@@ -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 (file)
index 0000000..7e8eaf5
--- /dev/null
@@ -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 (file)
index 0000000..fdb9078
--- /dev/null
@@ -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 (file)
index 0000000..3f70938
--- /dev/null
@@ -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 (file)
index 0000000..dbfa3d6
--- /dev/null
@@ -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;
index 0bd6af1386b5621105707599528bdcb22d0f5883..a705491096d234ecfeaf6cf5cd56219f49b06f0b 100644 (file)
 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 (file)
index 0000000..debfde6
--- /dev/null
@@ -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 (file)
index 0000000..01eef12
--- /dev/null
@@ -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 (file)
index 0000000..f1bf082
--- /dev/null
@@ -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 (file)
index 0000000..0034d8f
--- /dev/null
@@ -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 (file)
index 0000000..8527145
--- /dev/null
@@ -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 (file)
index 0000000..26b8889
--- /dev/null
@@ -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 (file)
index 0000000..5d9e14c
--- /dev/null
@@ -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 (file)
index 0000000..ca2a6db
--- /dev/null
@@ -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 (file)
index 0000000..3852dde
--- /dev/null
@@ -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 (file)
index 0000000..6800ffa
--- /dev/null
@@ -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 (file)
index 0000000..c17f478
--- /dev/null
@@ -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 (file)
index 0000000..4aebb92
--- /dev/null
@@ -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 (file)
index 0000000..7f69d3c
--- /dev/null
@@ -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 (file)
index 0000000..b6a40c6
--- /dev/null
@@ -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();
+  }
+}
index 7394832467b2d09bba07161a02048724d025e653..87ef131bb2975ffa4115bae5445ec3b24aed8e61 100644 (file)
@@ -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 (file)
index 0000000..c2e0b81
--- /dev/null
@@ -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 (file)
index 0000000..2b9799b
--- /dev/null
@@ -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
index 6c54426c6910ea0e4e1fb7fbca9d37eeb184fb98..ad5226988ffba0eb9ebf661571b697e4ed1ffb5d 100644 (file)
 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);
   }
index 8e076cb9c2052d5a4eb8e02481a309e2bac20310..e5775c25f9f3705910831b8e6b2de02a43e0b15f 100644 (file)
@@ -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 (file)
index 0000000..785d61b
--- /dev/null
@@ -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 (file)
index 0000000..a44f47e
--- /dev/null
@@ -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);
+    }
+  }
+}