Browse Source

added initial implementation

pull/8723/head
dashevchenko 3 years ago
parent
commit
a85f5b4330
  1. 4
      application/pom.xml
  2. 5
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  3. 130
      application/src/main/java/org/thingsboard/server/controller/AdminController.java
  4. 82
      application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java
  5. 71
      application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
  6. 19
      application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java
  7. 75
      application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java
  8. 9
      application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java
  9. 32
      application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java
  10. 168
      application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java
  11. 52
      application/src/main/resources/templates/mail_config_templates.json
  12. 4
      application/src/main/resources/thingsboard.yml
  13. 7
      common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java
  14. 31
      common/data/src/main/java/org/thingsboard/server/common/data/mail/MailOauth2Provider.java
  15. 27
      dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java
  16. 6
      pom.xml
  17. 13
      ui-ngx/src/app/core/http/admin.service.ts
  18. 377
      ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html
  19. 89
      ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss
  20. 404
      ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts
  21. 42
      ui-ngx/src/app/shared/models/settings.models.ts
  22. 24
      ui-ngx/src/assets/locale/locale.constant-en_US.json

4
application/pom.xml

@ -354,6 +354,10 @@
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>
</dependency>
<dependency>
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client</artifactId>
</dependency>
</dependencies>
<build>

5
application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java

@ -77,6 +77,8 @@ public class ThingsboardSecurityConfiguration {
protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**"};
public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
public static final String WS_TOKEN_BASED_AUTH_ENTRY_POINT = "/api/ws/**";
public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code";
@Autowired private ThingsboardErrorResponseHandler restAccessDeniedHandler;
@ -134,7 +136,7 @@ public class ThingsboardSecurityConfiguration {
protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception {
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS));
pathsToSkip.addAll(Arrays.asList(WS_TOKEN_BASED_AUTH_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT,
PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT));
PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT));
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
JwtTokenAuthenticationProcessingFilter filter
= new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher);
@ -201,6 +203,7 @@ public class ThingsboardSecurityConfiguration {
.antMatchers(FORM_BASED_LOGIN_ENTRY_POINT).permitAll() // Login end-point
.antMatchers(PUBLIC_LOGIN_ENTRY_POINT).permitAll() // Public login end-point
.antMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point
.antMatchers(MAIL_OAUTH2_PROCESSING_ENTRY_POINT).permitAll() // Mail oauth2 code processing url
.antMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points
.and()
.authorizeRequests()

130
application/src/main/java/org/thingsboard/server/controller/AdminController.java

@ -15,13 +15,24 @@
*/
package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest;
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@ -32,18 +43,25 @@ 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.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.FeaturesInfo;
import org.thingsboard.server.common.data.FeaturesInfo;
import org.thingsboard.server.common.data.SystemInfo;
import org.thingsboard.server.common.data.UpdateMessage;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.security.model.JwtSettings;
@ -56,6 +74,7 @@ import org.thingsboard.server.common.data.sync.vc.VcUtils;
import org.thingsboard.server.dao.audit.AuditLogService;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.oauth2.CookieUtils;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
@ -67,15 +86,29 @@ import org.thingsboard.server.service.sync.vc.autocommit.TbAutoCommitSettingsSer
import org.thingsboard.server.service.system.SystemInfoService;
import org.thingsboard.server.service.update.UpdateService;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import static org.thingsboard.server.controller.ControllerConstants.*;
import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
@RestController
@TbCoreComponent
@Slf4j
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController extends BaseController {
private static final String PREV_URI_PATH_PARAMETER = "prevUri";
private static final String PREV_URI_COOKIE_NAME = "prev_uri";
private static final String STATE_COOKIE_NAME = "state";
private static final String MAIL_SETTINGS_KEY = "mail";
private final MailService mailService;
private final SmsService smsService;
private final AdminSettingsService adminSettingsService;
@ -102,6 +135,7 @@ public class AdminController extends BaseController {
AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key), "No Administration settings found for key: " + key);
if (adminSettings.getKey().equals("mail")) {
((ObjectNode) adminSettings.getJsonValue()).remove("password");
((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken");
}
return adminSettings;
}
@ -122,6 +156,7 @@ public class AdminController extends BaseController {
if (adminSettings.getKey().equals("mail")) {
mailService.updateMailConfiguration();
((ObjectNode) adminSettings.getJsonValue()).remove("password");
((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken");
} else if (adminSettings.getKey().equals("sms")) {
smsService.updateSmsConfiguration();
}
@ -188,9 +223,20 @@ public class AdminController extends BaseController {
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ);
adminSettings = checkNotNull(adminSettings);
if (adminSettings.getKey().equals("mail")) {
if (!adminSettings.getJsonValue().has("password")) {
if (adminSettings.getJsonValue().has("enableOauth2") && adminSettings.getJsonValue().get("enableOauth2").asBoolean()){
AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"));
((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText());
JsonNode refreshToken = mailSettings.getJsonValue().get("refreshToken");
if (refreshToken == null) {
throw new ThingsboardException("Refresh token was not generated. Please, generate refresh token.", ThingsboardErrorCode.GENERAL);
}
ObjectNode settings = (ObjectNode) adminSettings.getJsonValue();
settings.put("refreshToken", refreshToken.asText());
}
else {
if (!adminSettings.getJsonValue().has("password")) {
AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"));
((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText());
}
}
String email = getCurrentUser().getEmail();
mailService.sendTestMail(adminSettings.getJsonValue(), email);
@ -362,4 +408,84 @@ public class AdminController extends BaseController {
return systemInfoService.getFeaturesInfo();
}
@ApiOperation(value = "Get OAuth2 log in processing URL (getMailProcessingUrl)", notes = "Returns the URL enclosed in " +
"double quotes. After successful authentication with OAuth2 provider and user consent for requested scope, it makes a redirect to this path so that the platform can do " +
"further log in processing and generating access tokens. " + SYSTEM_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
@RequestMapping(value = "/mail/oauth2/loginProcessingUrl", method = RequestMethod.GET)
@ResponseBody
public String getMailProcessingUrl() throws ThingsboardException {
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ);
return "\"/api/admin/mail/oauth2/code\"";
}
@ApiOperation(value = "Redirect user to mail provider login page. ", notes = "After user logged in and provided access" +
"provider sends authorization code to specified redirect uri.)" )
@PreAuthorize("hasAuthority('SYS_ADMIN')")
@RequestMapping(value = "/mail/oauth2/authorize", method = RequestMethod.GET, produces = "application/text")
public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException {
String state = StringUtils.generateSafeToken();
if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) {
CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PATH_PARAMETER), 180);
}
CookieUtils.addCookie(response, STATE_COOKIE_NAME, state, 180);
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ);
AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY), "No Administration mail settings found");
JsonNode jsonValue = adminSettings.getJsonValue();
String clientId = checkNotNull(jsonValue.get("clientId"), "No clientId was configured").asText();
String authUri = checkNotNull(jsonValue.get("authUri"), "No authorization uri was configured").asText();
String redirectUri = checkNotNull(jsonValue.get("redirectUri"), "No Redirect uri was configured").asText();
List<String> scope = JacksonUtil.convertValue(checkNotNull(jsonValue.get("scope"), "No scope was configured"), new TypeReference<>() {
});
return "\"" + new AuthorizationCodeRequestUrl(authUri, clientId)
.setScopes(scope)
.setState(state)
.setRedirectUri(redirectUri)
.build() + "\"";
}
@RequestMapping(value = "/mail/oauth2/code", params = {"code", "state"}, method = RequestMethod.GET)
public void codeProcessingUrl(
@RequestParam(value = "code") String code, @RequestParam(value = "state") String state,
HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException {
Optional<Cookie> prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME);
Optional<Cookie> cookieState = CookieUtils.getCookie(request, STATE_COOKIE_NAME);
String baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request);
String prevUri = baseUrl + (prevUrlOpt.isPresent() ? prevUrlOpt.get().getValue(): "/settings/outgoing-mail");
if (cookieState.isEmpty() || !cookieState.get().getValue().equals(state)) {
CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME);
throw new ThingsboardException("Refresh token was not generated, invalid state param", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
}
CookieUtils.deleteCookie(request, response, STATE_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME);
AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY), "No Administration mail settings found");
JsonNode jsonValue = adminSettings.getJsonValue();
String clientId = checkNotNull(jsonValue.get("clientId"), "No clientId was configured").asText();
String clientSecret = checkNotNull(jsonValue.get("clientSecret"), "No client secret was configured").asText();
String clientRedirectUri = checkNotNull(jsonValue.get("redirectUri"), "No Redirect uri was configured").asText();
String tokenUri = checkNotNull(jsonValue.get("tokenUri"), "No authorization uri was configured").asText();
TokenResponse tokenResponse;
try {
tokenResponse = new AuthorizationCodeTokenRequest(new NetHttpTransport(), new GsonFactory(), new GenericUrl(tokenUri), code)
.setRedirectUri(clientRedirectUri)
.setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret))
.execute();
} catch (IOException e) {
log.warn("Unable to retrieve refresh token: {}", e.getMessage());
throw new ThingsboardException("Error while requesting access token: " + e.getMessage(), ThingsboardErrorCode.GENERAL);
}
((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken());
((ObjectNode)jsonValue).put("tokenGenerated", true);
adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings);
response.sendRedirect(prevUri);
}
}

82
application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java

@ -0,0 +1,82 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.nimbusds.jose.shaded.json.JSONObject;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.CharEncoding;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.mail.TbMailConfigTemplateService;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH;
@RestController
@TbCoreComponent
@RequiredArgsConstructor
@RequestMapping("/api/mail/config/template")
@Slf4j
public class MailConfigTemplateController extends BaseController {
private static final String MAIL_CONFIG_TEMPLATE_ID = "mailConfigTemplateId";
private static final String MAIL_CONFIG_TEMPLATE_DEFINITION = "Mail configuration template is set of default smtp settings for mail server that specific provider supports";
private final TbMailConfigTemplateService mailConfigTemplateService;
@ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH,
notes = MAIL_CONFIG_TEMPLATE_DEFINITION)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@RequestMapping(method = RequestMethod.GET, produces = "application/json")
@ResponseBody
public JsonNode getClientRegistrationTemplates() throws ThingsboardException, IOException {
accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ);
return mailConfigTemplateService.findAllMailConfigTemplates();
}
}

71
application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java

@ -16,6 +16,7 @@
package org.thingsboard.server.service.mail;
import com.fasterxml.jackson.databind.JsonNode;
import freemarker.template.Configuration;
import freemarker.template.Template;
import lombok.extern.slf4j.Slf4j;
@ -53,7 +54,6 @@ import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ -61,7 +61,6 @@ import java.util.concurrent.TimeoutException;
@Slf4j
public class DefaultMailService implements MailService {
public static final String MAIL_PROP = "mail.";
public static final String TARGET_EMAIL = "targetEmail";
public static final String UTF_8 = "UTF-8";
@ -82,7 +81,10 @@ public class DefaultMailService implements MailService {
@Autowired
private PasswordResetExecutorService passwordResetExecutorService;
private JavaMailSenderImpl mailSender;
@Autowired
private TbMailContextComponent tbMailContextComponent;
private TbMailSender mailSender;
private String mailFrom;
@ -105,7 +107,7 @@ public class DefaultMailService implements MailService {
AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail");
if (settings != null) {
JsonNode jsonConfig = settings.getJsonValue();
mailSender = createMailSender(jsonConfig);
mailSender = new TbMailSender(tbMailContextComponent, jsonConfig);
mailFrom = jsonConfig.get("mailFrom").asText();
timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT);
} else {
@ -113,65 +115,6 @@ public class DefaultMailService implements MailService {
}
}
private JavaMailSenderImpl createMailSender(JsonNode jsonConfig) {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(jsonConfig.get("smtpHost").asText());
mailSender.setPort(parsePort(jsonConfig.get("smtpPort").asText()));
mailSender.setUsername(jsonConfig.get("username").asText());
mailSender.setPassword(jsonConfig.get("password").asText());
mailSender.setJavaMailProperties(createJavaMailProperties(jsonConfig));
return mailSender;
}
private Properties createJavaMailProperties(JsonNode jsonConfig) {
Properties javaMailProperties = new Properties();
String protocol = jsonConfig.get("smtpProtocol").asText();
javaMailProperties.put("mail.transport.protocol", protocol);
javaMailProperties.put(MAIL_PROP + protocol + ".host", jsonConfig.get("smtpHost").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".port", jsonConfig.get("smtpPort").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".timeout", jsonConfig.get("timeout").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".auth", String.valueOf(StringUtils.isNotEmpty(jsonConfig.get("username").asText())));
boolean enableTls = false;
if (jsonConfig.has("enableTls")) {
if (jsonConfig.get("enableTls").isBoolean() && jsonConfig.get("enableTls").booleanValue()) {
enableTls = true;
} else if (jsonConfig.get("enableTls").isTextual()) {
enableTls = "true".equalsIgnoreCase(jsonConfig.get("enableTls").asText());
}
}
javaMailProperties.put(MAIL_PROP + protocol + ".starttls.enable", enableTls);
if (enableTls && jsonConfig.has("tlsVersion") && !jsonConfig.get("tlsVersion").isNull()) {
String tlsVersion = jsonConfig.get("tlsVersion").asText();
if (StringUtils.isNoneEmpty(tlsVersion)) {
javaMailProperties.put(MAIL_PROP + protocol + ".ssl.protocols", tlsVersion);
}
}
boolean enableProxy = jsonConfig.has("enableProxy") && jsonConfig.get("enableProxy").asBoolean();
if (enableProxy) {
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.host", jsonConfig.get("proxyHost").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.port", jsonConfig.get("proxyPort").asText());
String proxyUser = jsonConfig.get("proxyUser").asText();
if (StringUtils.isNoneEmpty(proxyUser)) {
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.user", proxyUser);
}
String proxyPassword = jsonConfig.get("proxyPassword").asText();
if (StringUtils.isNoneEmpty(proxyPassword)) {
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.password", proxyPassword);
}
}
return javaMailProperties;
}
private int parsePort(String strPort) {
try {
return Integer.valueOf(strPort);
} catch (NumberFormatException e) {
throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort));
}
}
@Override
public void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException {
sendMail(mailSender, mailFrom, email, subject, message, timeout);
@ -179,7 +122,7 @@ public class DefaultMailService implements MailService {
@Override
public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException {
JavaMailSenderImpl testMailSender = createMailSender(jsonConfig);
TbMailSender testMailSender = new TbMailSender(tbMailContextComponent, jsonConfig);
String mailFrom = jsonConfig.get("mailFrom").asText();
String subject = messages.getMessage("test.message.subject", null, Locale.US);
long timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT);

19
application/src/main/java/org/thingsboard/server/service/mail/DefaultTbMailConfigTemplateService.java

@ -0,0 +1,19 @@
package org.thingsboard.server.service.mail;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import java.io.IOException;
@Service
@Slf4j
public class DefaultTbMailConfigTemplateService implements TbMailConfigTemplateService {
@Override
public JsonNode findAllMailConfigTemplates() throws IOException {
return JacksonUtil.toJsonNode(new ClassPathResource("/templates/mail_config_templates.json").getFile());
}
}

75
application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java

@ -0,0 +1,75 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.mail;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
import com.google.api.client.auth.oauth2.RefreshTokenRequest;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import static org.thingsboard.server.common.data.mail.MailOauth2Provider.OFFICE_365;
@TbCoreComponent
@Service
@Slf4j
@RequiredArgsConstructor
public class RefreshTokenExpCheckService {
public static final int AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS = 90;
private final AdminSettingsService adminSettingsService;
@Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${mail.oauth2.refreshTokenCheckingInterval})}", fixedDelayString = "${mail.oauth2.refreshTokenCheckingInterval}")
public void check() throws IOException {
AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail");
if (settings != null && settings.getJsonValue().has("enableOauth2") && settings.getJsonValue().get("enableOauth2").asBoolean()) {
JsonNode jsonValue = settings.getJsonValue();
if (OFFICE_365.name().equals(jsonValue.get("providerId").asText()) && jsonValue.has("refreshTokenExpires")) {
long expiresIn = jsonValue.get("refreshTokenExpires").longValue();
if ((expiresIn - System.currentTimeMillis()) < 604800000L) { //less than 7 days
log.info("Trying to refresh refresh token.");
String clientId = jsonValue.get("clientId").asText();
String clientSecret = jsonValue.get("clientSecret").asText();
String refreshToken = jsonValue.get("refreshToken").asText();
String tokenUri = jsonValue.get("tokenUri").asText();
TokenResponse tokenResponse = new RefreshTokenRequest(new NetHttpTransport(), new GsonFactory(),
new GenericUrl(tokenUri), refreshToken)
.setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret))
.execute();
((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken());
((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli());
adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, settings);
}
}
}
}
}

9
application/src/main/java/org/thingsboard/server/service/mail/TbMailConfigTemplateService.java

@ -0,0 +1,9 @@
package org.thingsboard.server.service.mail;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
public interface TbMailConfigTemplateService {
JsonNode findAllMailConfigTemplates() throws IOException;
}

32
application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java

@ -0,0 +1,32 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.mail;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
@Component
@Data
@Lazy
public class TbMailContextComponent {
@Autowired
private AdminSettingsService adminSettingsService;
}

168
application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java

@ -0,0 +1,168 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.mail;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
import com.google.api.client.auth.oauth2.RefreshTokenRequest;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.mail.MailOauth2Provider;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import javax.mail.internet.MimeMessage;
import java.time.Duration;
import java.time.Instant;
import java.util.Properties;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import static org.thingsboard.server.service.mail.RefreshTokenExpCheckService.AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS;
@Slf4j
public class TbMailSender extends JavaMailSenderImpl {
private static final String MAIL_PROP = "mail.";
private final TbMailContextComponent ctx;
private final Lock lock;
private final Boolean oauth2Enabled;
private volatile String accessToken;
private volatile long tokenExpires;
public TbMailSender(TbMailContextComponent ctx, JsonNode jsonConfig) {
super();
this.lock = new ReentrantLock();
this.tokenExpires = 0L;
this.ctx = ctx;
this.oauth2Enabled = jsonConfig.has("enableOauth2") && jsonConfig.get("enableOauth2").asBoolean();
setHost(jsonConfig.get("smtpHost").asText());
setPort(parsePort(jsonConfig.get("smtpPort").asText()));
setUsername(jsonConfig.get("username").asText());
if (jsonConfig.has("password")) {
setPassword(jsonConfig.get("password").asText());
}
setJavaMailProperties(createJavaMailProperties(jsonConfig));
}
@SneakyThrows
@Override
public void doSend(MimeMessage[] mimeMessages, @Nullable Object[] originalMessages) {
if (oauth2Enabled && (System.currentTimeMillis() > tokenExpires)){
refreshAccessToken();
setPassword(accessToken);
}
super.doSend(mimeMessages, originalMessages);
}
private Properties createJavaMailProperties(JsonNode jsonConfig) {
Properties javaMailProperties = new Properties();
String protocol = jsonConfig.get("smtpProtocol").asText();
javaMailProperties.put("mail.transport.protocol", protocol);
javaMailProperties.put(MAIL_PROP + protocol + ".host", jsonConfig.get("smtpHost").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".port", jsonConfig.get("smtpPort").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".timeout", jsonConfig.get("timeout").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".auth", String.valueOf(StringUtils.isNotEmpty(jsonConfig.get("username").asText())));
boolean enableTls = false;
if (jsonConfig.has("enableTls")) {
if (jsonConfig.get("enableTls").isBoolean() && jsonConfig.get("enableTls").booleanValue()) {
enableTls = true;
} else if (jsonConfig.get("enableTls").isTextual()) {
enableTls = "true".equalsIgnoreCase(jsonConfig.get("enableTls").asText());
}
}
javaMailProperties.put(MAIL_PROP + protocol + ".starttls.enable", enableTls);
if (enableTls && jsonConfig.has("tlsVersion") && !jsonConfig.get("tlsVersion").isNull()) {
String tlsVersion = jsonConfig.get("tlsVersion").asText();
if (StringUtils.isNoneEmpty(tlsVersion)) {
javaMailProperties.put(MAIL_PROP + protocol + ".ssl.protocols", tlsVersion);
}
}
boolean enableProxy = jsonConfig.has("enableProxy") && jsonConfig.get("enableProxy").asBoolean();
if (enableProxy) {
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.host", jsonConfig.get("proxyHost").asText());
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.port", jsonConfig.get("proxyPort").asText());
String proxyUser = jsonConfig.get("proxyUser").asText();
if (StringUtils.isNoneEmpty(proxyUser)) {
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.user", proxyUser);
}
String proxyPassword = jsonConfig.get("proxyPassword").asText();
if (StringUtils.isNoneEmpty(proxyPassword)) {
javaMailProperties.put(MAIL_PROP + protocol + ".proxy.password", proxyPassword);
}
}
if (oauth2Enabled) {
javaMailProperties.put(MAIL_PROP + protocol + ".auth.mechanisms", "XOAUTH2");
}
return javaMailProperties;
}
public void refreshAccessToken() throws ThingsboardException {
lock.lock();
try {
if (System.currentTimeMillis() > tokenExpires) {
AdminSettings settings = ctx.getAdminSettingsService().findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail");
JsonNode jsonValue = settings.getJsonValue();
String clientId = jsonValue.get("clientId").asText();
String clientSecret = jsonValue.get("clientSecret").asText();
String refreshToken = jsonValue.get("refreshToken").asText();
String tokenUri = jsonValue.get("tokenUri").asText();
String providerId = jsonValue.get("providerId").asText();
TokenResponse tokenResponse = new RefreshTokenRequest(new NetHttpTransport(), new GsonFactory(),
new GenericUrl(tokenUri), refreshToken)
.setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret))
.execute();
if (MailOauth2Provider.OFFICE_365.name().equals(providerId)) {
((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken());
((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli());
ctx.getAdminSettingsService().saveAdminSettings(TenantId.SYS_TENANT_ID, settings);
}
accessToken = tokenResponse.getAccessToken();
tokenExpires = System.currentTimeMillis() + (tokenResponse.getExpiresInSeconds().intValue() * 1000);
}
} catch (Exception e) {
log.warn("Unable to retrieve access token: {}", e.getMessage());
throw new ThingsboardException("Error while retrieving access token: " + e.getMessage(), ThingsboardErrorCode.GENERAL);
} finally {
lock.unlock();
}
}
private int parsePort(String strPort) {
try {
return Integer.parseInt(strPort);
} catch (NumberFormatException e) {
throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort));
}
}
}

52
application/src/main/resources/templates/mail_config_templates.json

@ -0,0 +1,52 @@
[
{
"providerId": "SENDGRID",
"smtpProtocol": "SMTPS",
"smtpHost": "smtp.sendgrid.net",
"smtpPort": 465,
"timeout": 10000,
"enableTls": true,
"tlsVersion": "TLSv1.2",
"authorizationUri": null,
"accessTokenUri": null,
"scope": [
""
],
"helpLink": null,
"name": "SendGrid"
},
{
"providerId": "GOOGLE",
"smtpProtocol": "SMTPS",
"smtpHost": "smtp.gmail.com",
"smtpPort": 465,
"timeout": 10000,
"enableTls": true,
"tlsVersion": "TLSv1.2",
"authorizationUri": "https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline",
"accessTokenUri": "https://oauth2.googleapis.com/token",
"scope": [
"https://mail.google.com/"
],
"helpLink": "https://support.google.com/googleapi/answer/6158849",
"name": "Google"
},
{
"providerId": "OFFICE_365",
"smtpProtocol": "SMTP",
"smtpHost": "smtp.office365.com",
"smtpPort": 587,
"timeout": 10000,
"enableTls": true,
"tlsVersion": "TLSv1.2",
"authorizationUri": "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize",
"accessTokenUri": "https://login.microsoftonline.com/%s/oauth2/v2.0/token",
"scope": [
"https://outlook.office365.com/SMTP.Send",
"offline_access",
"openid"
],
"helpLink": "https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth",
"name": "Office 365"
}
]

4
application/src/main/resources/thingsboard.yml

@ -135,6 +135,10 @@ security:
path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}"
password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}"
mail:
oauth2:
refreshTokenCheckingInterval: "${REFRESH_TOKEN_EXPIRATION_CHECKING_INTERVAL:86400}" # Number of seconds (1 day).
# Usage statistics parameters
usage:
stats:

7
common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java

@ -24,6 +24,9 @@ import java.util.Base64;
import static org.apache.commons.lang3.StringUtils.repeat;
public class StringUtils {
private static final int DEFAULT_TOKEN_LENGTH = 8;
public static final SecureRandom RANDOM = new SecureRandom();
public static final String EMPTY = "";
@ -205,4 +208,8 @@ public class StringUtils {
return encoder.encodeToString(bytes);
}
public static String generateSafeToken() {
return generateSafeToken(DEFAULT_TOKEN_LENGTH);
}
}

31
common/data/src/main/java/org/thingsboard/server/common/data/mail/MailOauth2Provider.java

@ -0,0 +1,31 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.mail;
public enum MailOauth2Provider {
GOOGLE("Google"), OFFICE_365("Office 365"), SENDGRID("SendGrid"), CUSTOM("Custom");
public final String label;
MailOauth2Provider(String label) {
this.label = label;
}
@Override
public String toString() {
return label;
}
}

27
dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.dao.settings;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@ -58,10 +59,18 @@ public class AdminSettingsServiceImpl implements AdminSettingsService {
public AdminSettings saveAdminSettings(TenantId tenantId, AdminSettings adminSettings) {
log.trace("Executing saveAdminSettings [{}]", adminSettings);
adminSettingsValidator.validate(adminSettings, data -> tenantId);
if (adminSettings.getKey().equals("mail") && !adminSettings.getJsonValue().has("password")) {
if (adminSettings.getKey().equals("mail")){
AdminSettings mailSettings = findAdminSettingsByKey(tenantId, "mail");
if (mailSettings != null) {
((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText());
JsonNode newJsonValue = adminSettings.getJsonValue();
JsonNode oldJsonValue = mailSettings.getJsonValue();
if (!newJsonValue.has("password") && oldJsonValue.has("password")){
((ObjectNode) newJsonValue).put("password", oldJsonValue.get("password").asText());
}
if (!newJsonValue.has("refreshToken") && oldJsonValue.has("refreshToken")){
((ObjectNode) newJsonValue).put("refreshToken", oldJsonValue.get("refreshToken").asText());
}
dropTokenIfProviderInfoChanged(newJsonValue, oldJsonValue);
}
}
if (adminSettings.getTenantId() == null) {
@ -82,4 +91,18 @@ public class AdminSettingsServiceImpl implements AdminSettingsService {
adminSettingsDao.removeByTenantId(tenantId.getId());
}
private void dropTokenIfProviderInfoChanged(JsonNode newJsonValue, JsonNode oldJsonValue) {
if (newJsonValue.has("enableOauth2") && newJsonValue.get("enableOauth2").asBoolean()){
if (!newJsonValue.get("providerId").equals(oldJsonValue.get("providerId")) ||
!newJsonValue.get("clientId").equals(oldJsonValue.get("clientId")) ||
!newJsonValue.get("clientSecret").equals(oldJsonValue.get("clientSecret")) ||
!newJsonValue.get("redirectUri").equals(oldJsonValue.get("redirectUri")) ||
(newJsonValue.has("providerTenantId") && !newJsonValue.get("providerTenantId").equals(oldJsonValue.get("providerTenantId")))){
((ObjectNode) newJsonValue).put("tokenGenerated", false);
((ObjectNode) newJsonValue).remove("refreshToken");
((ObjectNode) newJsonValue).remove("refreshTokenExpires");
}
}
}
}

6
pom.xml

@ -151,6 +151,7 @@
<allure-maven.version>2.12.0</allure-maven.version>
<slack-api.version>1.12.1</slack-api.version>
<oshi.version>6.4.2</oshi.version>
<google-oauth-client.version>1.34.1</google-oauth-client.version>
</properties>
<modules>
@ -2017,6 +2018,11 @@
<artifactId>oshi-core</artifactId>
<version>${oshi.version}</version>
</dependency>
<dependency>
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client</artifactId>
<version>${google-oauth-client.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

13
ui-ngx/src/app/core/http/admin.service.ts

@ -21,6 +21,7 @@ import { HttpClient } from '@angular/common/http';
import {
AdminSettings,
AutoCommitSettings,
MailConfigTemplate,
FeaturesInfo,
JwtSettings,
MailServerSettings,
@ -136,4 +137,16 @@ export class AdminService {
public getFeaturesInfo(config?: RequestConfig): Observable<FeaturesInfo> {
return this.http.get<FeaturesInfo>('/api/admin/featuresInfo', defaultHttpOptionsFromConfig(config));
}
public getLoginProcessingUrl(config?: RequestConfig): Observable<string> {
return this.http.get<string>(`/api/admin/mail/oauth2/loginProcessingUrl`, defaultHttpOptionsFromConfig(config));
}
public generateAccessToken(config?: RequestConfig): Observable<string> {
return this.http.get<string>(`/api/admin/mail/oauth2/authorize`, defaultHttpOptionsFromConfig(config));
}
public getMailConfigTemplate(config?: RequestConfig): Observable<Array<MailConfigTemplate>> {
return this.http.get<Array<MailConfigTemplate>>('/api/mail/config/template', defaultHttpOptionsFromConfig(config));
}
}

377
ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html

@ -37,111 +37,306 @@
{{ 'admin.mail-from-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>admin.smtp-protocol</mat-label>
<mat-select formControlName="smtpProtocol">
<mat-option *ngFor="let protocol of smtpProtocols" [value]="protocol">
{{protocol.toUpperCase()}}
</mat-option>
</mat-select>
</mat-form-field>
<div fxLayout.gt-sm="row" fxLayoutGap.gt-sm="10px">
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="60">
<mat-label translate>admin.smtp-host</mat-label>
<input matInput formControlName="smtpHost" placeholder="localhost" required/>
<mat-error *ngIf="mailSettings.get('smtpHost').hasError('required')">
{{ 'admin.smtp-host-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="40">
<mat-label translate>admin.smtp-port</mat-label>
<input matInput #smtpPortInput formControlName="smtpPort" placeholder="25" maxlength="5" required/>
<mat-hint align="end">{{smtpPortInput.value?.length || 0}}/5</mat-hint>
<mat-error *ngIf="mailSettings.get('smtpPort').hasError('required')">
{{ 'admin.smtp-port-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('smtpPort').hasError('pattern') || mailSettings.get('smtpPort').hasError('maxlength')">
{{ 'admin.smtp-port-invalid' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>admin.timeout-msec</mat-label>
<input matInput #timeoutInput formControlName="timeout" placeholder="10000" maxlength="6" required/>
<mat-hint align="end">{{timeoutInput.value?.length || 0}}/6</mat-hint>
<mat-error *ngIf="mailSettings.get('timeout').hasError('required')">
{{ 'admin.timeout-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('timeout').hasError('pattern') || mailSettings.get('timeout').hasError('maxlength')">
{{ 'admin.timeout-invalid' | translate }}
</mat-error>
</mat-form-field>
<tb-checkbox formControlName="enableTls" style="display: block; padding-bottom: 16px;">
{{ 'admin.enable-tls' | translate }}
</tb-checkbox>
<mat-form-field class="mat-block" *ngIf="mailSettings.get('enableTls').value">
<mat-label translate>admin.tls-version</mat-label>
<mat-select formControlName="tlsVersion">
<mat-option *ngFor="let tlsVersion of tlsVersions" [value]="tlsVersion">
{{ tlsVersion }}
<mat-label translate>admin.oauth2.smtp-provider</mat-label>
<mat-select formControlName="providerId">
<mat-option *ngFor="let provider of templateProvider" [value]="provider">
{{ templates.get(provider)?.name || 'Custom' }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-checkbox formControlName="enableProxy" style="display: block; padding-bottom: 16px;">
{{ 'admin.enable-proxy' | translate }}
</tb-checkbox>
<div *ngIf="mailSettings.get('enableProxy').value">
<div fxLayout.gt-sm="row" fxLayoutGap.gt-sm="8px">
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="60">
<mat-label translate>admin.proxy-host</mat-label>
<input matInput required formControlName="proxyHost">
<mat-error *ngIf="mailSettings.get('proxyHost').hasError('required')">
{{ 'admin.proxy-host-required' | translate }}
</mat-error>
<mat-expansion-panel class="configuration-panel mat-elevation-z0" [expanded]="mailSettings.get('providerId').value === mailServerOauth2Provider.CUSTOM">
<mat-expansion-panel-header>
<mat-panel-title fxLayoutAlign="start center" translate>
admin.connection-settings
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-form-field class="mat-block">
<mat-label translate>admin.smtp-protocol</mat-label>
<mat-select formControlName="smtpProtocol">
<mat-option *ngFor="let protocol of smtpProtocols" [value]="protocol">
{{protocol.toUpperCase()}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="40">
<mat-label translate>admin.proxy-port</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1" min="1" max="65535">
<mat-error *ngIf="mailSettings.get('proxyPort').hasError('required')">
{{ 'admin.proxy-port-required' | translate }}
<div fxLayout.gt-sm="row" fxLayoutGap.gt-sm="10px">
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="60">
<mat-label translate>admin.smtp-host</mat-label>
<input matInput formControlName="smtpHost" placeholder="localhost" required/>
<mat-error *ngIf="mailSettings.get('smtpHost').hasError('required')">
{{ 'admin.smtp-host-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="40">
<mat-label translate>admin.smtp-port</mat-label>
<input matInput #smtpPortInput formControlName="smtpPort" placeholder="25" maxlength="5" required/>
<mat-hint align="end">{{smtpPortInput.value?.length || 0}}/5</mat-hint>
<mat-error *ngIf="mailSettings.get('smtpPort').hasError('required')">
{{ 'admin.smtp-port-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('smtpPort').hasError('pattern') || mailSettings.get('smtpPort').hasError('maxlength')">
{{ 'admin.smtp-port-invalid' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>admin.timeout-msec</mat-label>
<input matInput #timeoutInput formControlName="timeout" placeholder="10000" maxlength="6" required/>
<mat-hint align="end">{{timeoutInput.value?.length || 0}}/6</mat-hint>
<mat-error *ngIf="mailSettings.get('timeout').hasError('required')">
{{ 'admin.timeout-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('proxyPort').hasError('pattern')
|| mailSettings.get('proxyPort').hasError('min')
|| mailSettings.get('proxyPort').hasError('max')">
{{ 'admin.proxy-port-range' | translate }}
<mat-error *ngIf="mailSettings.get('timeout').hasError('pattern') || mailSettings.get('timeout').hasError('maxlength')">
{{ 'admin.timeout-invalid' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>admin.proxy-user</mat-label>
<input matInput formControlName="proxyUser">
</mat-form-field>
<mat-slide-toggle fxFlex formControlName="enableTls" style="display: block; padding-bottom: 22px;">
{{ 'admin.enable-tls' | translate }}
</mat-slide-toggle>
<mat-form-field fxFlex class="mat-block" *ngIf="mailSettings.get('enableTls').value">
<mat-label translate>admin.tls-version</mat-label>
<mat-select formControlName="tlsVersion">
<mat-option *ngFor="let tlsVersion of tlsVersions" [value]="tlsVersion">
{{ tlsVersion }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-slide-toggle formControlName="enableProxy" style="display: block; padding-bottom: 22px;">
{{ 'admin.enable-proxy' | translate }}
</mat-slide-toggle>
<div *ngIf="mailSettings.get('enableProxy').value">
<div fxLayout.gt-sm="row" fxLayoutGap.gt-sm="8px">
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="60">
<mat-label translate>admin.proxy-host</mat-label>
<input matInput required formControlName="proxyHost">
<mat-error *ngIf="mailSettings.get('proxyHost').hasError('required')">
{{ 'admin.proxy-host-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" fxFlex="100" fxFlex.gt-sm="40">
<mat-label translate>admin.proxy-port</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1" min="1" max="65535">
<mat-error *ngIf="mailSettings.get('proxyPort').hasError('required')">
{{ 'admin.proxy-port-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('proxyPort').hasError('pattern')
|| mailSettings.get('proxyPort').hasError('min')
|| mailSettings.get('proxyPort').hasError('max')">
{{ 'admin.proxy-port-range' | translate }}
</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.proxy-user</mat-label>
<input matInput formControlName="proxyUser">
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.proxy-password</mat-label>
<input matInput type="password" formControlName="proxyPassword" autocomplete="new-proxy-password">
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</div>
</div>
</ng-template>
</mat-expansion-panel>
<fieldset class="fields-group" fxLayout="column">
<legend class="group-title" translate>admin.oauth2.authentication</legend>
<mat-form-field class="mat-block">
<mat-label translate>admin.proxy-password</mat-label>
<input matInput type="password" formControlName="proxyPassword" autocomplete="new-proxy-password">
<tb-toggle-password matSuffix></tb-toggle-password>
<mat-label translate>common.username</mat-label>
<input matInput formControlName="username" placeholder="{{ 'common.enter-username' | translate }}"
autocomplete="new-username"/>
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>common.username</mat-label>
<input matInput formControlName="username" placeholder="{{ 'common.enter-username' | translate }}"
autocomplete="new-username"/>
</mat-form-field>
<mat-checkbox *ngIf="showChangePassword" formControlName="changePassword" style="padding-bottom: 16px;">
{{ 'admin.change-password' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block" *ngIf="mailSettings.get('changePassword').value || !showChangePassword">
<mat-label translate>common.password</mat-label>
<input matInput formControlName="password" type="password"
placeholder="{{ 'common.enter-password' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
<div fxLayoutAlign="space-between center" style="height: 50px; padding-bottom: 20px" [fxHide]="mailSettings.get('providerId').value === 'SENDGRID'">
<mat-button-toggle-group class="tb-notification-unread-toggle-group"
style="width: 250px;"
formControlName="enableOauth2">
<mat-button-toggle fxFlex [value]=false>{{ 'admin.oauth2.basic' | translate }}</mat-button-toggle>
<mat-button-toggle fxFlex [value]=true>{{ 'admin.oauth2.oauth2' | translate }}</mat-button-toggle>
</mat-button-toggle-group>
<div class="details-buttons" *ngIf="helpLink && mailSettings.get('enableOauth2').value">
<div [tb-help]="helpLink"></div>
</div>
</div>
<section *ngIf="!mailSettings.get('enableOauth2').value">
<mat-checkbox *ngIf="showChangePassword" formControlName="changePassword" style="padding-bottom: 16px;">
{{ 'admin.change-password' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block" *ngIf="mailSettings.get('changePassword').value || !showChangePassword">
<mat-label translate>common.password</mat-label>
<input matInput formControlName="password" type="password"
placeholder="{{ 'common.enter-password' | translate }}" autocomplete="new-password"/>
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</section>
<section *ngIf="mailSettings.get('enableOauth2').value">
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.client-id</mat-label>
<input matInput formControlName="clientId" required>
<mat-error *ngIf="mailSettings.get('clientId').hasError('required')">
{{ 'admin.oauth2.client-id-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('clientId').hasError('maxlen gth')">
{{ 'admin.oauth2.client-id-max-length' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.client-secret</mat-label>
<input matInput formControlName="clientSecret" required>
<mat-error *ngIf="mailSettings.get('clientSecret').hasError('required')">
{{ 'admin.oauth2.client-secret-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('clientSecret').hasError('maxlength')">
{{ 'admin.oauth2.client-secret-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field fxFlex class="mat-block" *ngIf="mailSettings.get('providerId').value === mailServerOauth2Provider.OFFICE_365">
<mat-label translate>admin.oauth2.microsoft-tenant-id</mat-label>
<input matInput formControlName="providerTenantId" required>
<mat-error *ngIf="mailSettings.get('providerTenantId').hasError('required')">
{{ 'admin.oauth2.microsoft-tenant-id-required' | translate }}
</mat-error>
</mat-form-field>
<mat-expansion-panel class="mat-elevation-z0" [expanded]="mailSettings.get('providerId').value === mailServerOauth2Provider.CUSTOM">
<mat-expansion-panel-header>
<mat-panel-description fxLayoutAlign="end" translate>
tenant-profile.advanced-settings
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap.gt-xs="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.authorization-uri</mat-label>
<input matInput formControlName="authUri" required>
<button mat-icon-button matSuffix
type="button"
(click)="toggleEditMode('authUri')">
<mat-icon class="material-icons">create</mat-icon>
</button>
<mat-error *ngIf="mailSettings.get('authUri').hasError('required')">
{{ 'admin.oauth2.access-token-uri-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('authUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.token-uri</mat-label>
<input matInput formControlName="tokenUri" required>
<button mat-icon-button matSuffix
type="button"
(click)="toggleEditMode('tokenUri')">
<mat-icon class="material-icons">create</mat-icon>
</button>
<mat-error *ngIf="mailSettings.get('tokenUri').hasError('required')">
{{ 'admin.oauth2.access-token-uri-required' | translate }}
</mat-error>
<mat-error *ngIf="mailSettings.get('tokenUri').hasError('pattern')">
{{ 'admin.oauth2.uri-pattern-error' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.scope</mat-label>
<mat-chip-grid #scopeList>
<mat-chip-row *ngFor="let scope of mailSettings.get('scope').value; let k = index; trackBy: trackByParams"
removable (removed)="removeScope(k)">
{{scope}}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip-row>
<input [matChipInputFor]="scopeList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
matChipInputAddOnBlur
(matChipInputTokenEnd)="addScope($event)">
</mat-chip-grid>
<mat-error *ngIf="mailSettings.get('scope').hasError('required')">
{{ 'admin.oauth2.scope-required' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
</mat-expansion-panel>
<fieldset class="fields-group" fxLayout="column">
<legend class="group-title" translate>admin.oauth2.redirect-uri</legend>
<div fxFlex fxLayout="row" fxLayout.xs="column" fxLayoutGap="8px">
<div fxLayout="column" fxFlex.sm="60" fxFlex.gt-sm="50" [formGroup]="domainForm">
<div fxLayout="row" fxLayout.xs="column" fxLayout.md="column" fxLayoutGap="8px" fxLayoutGap.md="0px">
<mat-form-field fxFlex="30" fxFlex.md fxFlex.xs class="mat-block">
<mat-label translate>admin.oauth2.protocol</mat-label>
<mat-select formControlName="scheme">
<mat-option *ngFor="let protocol of protocols" [value]="protocol">
{{ domainSchemaTranslations.get(protocol) | translate | uppercase }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.domain-name</mat-label>
<input matInput formControlName="name" required>
<mat-error *ngIf="domainForm.get('name').hasError('pattern')">
{{ 'admin.error-verification-url' | translate }}
</mat-error>
<mat-error *ngIf="domainForm.get('name').hasError('maxlength')">
{{ 'admin.domain-name-max-length' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-error *ngIf="domainForm.hasError('unique')">
{{ 'admin.domain-name-unique' | translate }}
</mat-error>
</div>
<div fxFlex fxLayout="column">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>admin.oauth2.redirect-uri-template</mat-label>
<input matInput formControlName="redirectUri" readonly>
<tb-copy-button
matSuffix
color="primary"
[copyText]="mailSettings.get('redirectUri').value"
tooltipText="{{ 'admin.oauth2.copy-redirect-uri' | translate }}"
tooltipPosition="above"
mdiIcon="mdi:clipboard-arrow-left">
</tb-copy-button>
</mat-form-field>
</div>
</div>
</fieldset>
</section>
<section fxLayout="row"
fxLayout.xs="column"
fxLayoutAlign.gt-xs="space-between center"
style="padding-bottom: 12px"
*ngIf="mailSettings.get('enableOauth2').value">
<div>
<span class="token-status" translate>admin.oauth2.access-token-status</span>
<span>
{{ accessTokenStatus }}
</span>
</div>
<button mat-raised-button type="button" color="primary"
[disabled]="(isLoading$ | async) || mailSettings.invalid || domainForm.invalid || (mailSettings.dirty || domainForm.dirty)"
(click)="generateAccessToken()">
{{ accessTokenButtonName }}
</button>
</section>
</fieldset>
<div fxLayout="row" fxLayoutAlign="end center" fxLayout.xs="column" fxLayoutAlign.xs="end" fxLayoutGap="16px">
<button mat-raised-button type="button"
[disabled]="(isLoading$ | async) || mailSettings.invalid" (click)="sendTestMail()">
[disabled]="(isLoading$ | async) || mailSettings.invalid || domainForm.invalid || (mailSettings.dirty || domainForm.dirty)"
(click)="sendTestMail()">
{{'admin.send-test-mail' | translate}}
</button>
<button mat-raised-button color="primary" [disabled]="(isLoading$ | async) || mailSettings.invalid || !mailSettings.dirty"
<button mat-raised-button color="primary"
[disabled]="(isLoading$ | async) || mailSettings.invalid || domainForm.invalid || (!mailSettings.dirty && !domainForm.dirty)"
type="submit">{{'action.save' | translate}}
</button>
</div>

89
ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss

@ -13,6 +13,95 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../../../../../theme";
:host {
.fields-group {
padding: 0 8px 8px;
margin: 10px 0;
border: 1px groove rgba(0, 0, 0, .25);
border-radius: 4px;
legend {
margin-bottom: 8px;
color: rgba(0, 0, 0, .7);
width: fit-content;
}
}
.token-status {
font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif;
color: rgba(0,0,0, 0.6);
letter-spacing: 0.25px;
padding: 8px 0;
}
::ng-deep{
.mat-expansion-panel {
.mat-expansion-panel-header {
height: 48px;
padding: 0 12px;
&.mat-expanded {
height: 48px;
}
}
.mat-expansion-panel-body {
padding: 0 12px;
}
&.configuration-panel {
border: 1px solid rgba(0, 0, 0, 0.2);
}
}
.mat-button-toggle-group.tb-notification-unread-toggle-group {
&.mat-button-toggle-group-appearance-standard {
border: none;
border-radius: 14px;
.mat-button-toggle + .mat-button-toggle {
border-left: none;
}
}
.mat-button-toggle {
background: rgba(0, 0, 0, 0.06);
height: 28px;
align-items: center;
display: flex;
.mat-button-toggle-ripple {
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
border-radius: 12px;
}
}
.mat-button-toggle-button {
color: #959595;
}
.mat-button-toggle-focus-overlay {
border-radius: 14px;
margin: 2px;
}
.mat-button-toggle-checked .mat-button-toggle-button {
background-color: $tb-primary-color;
color: #fff;
border-radius: 14px;
margin-left: 2px;
margin-right: 2px;
}
.mat-button-toggle-appearance-standard .mat-button-toggle-label-content {
line-height: 24px;
font-size: 16px;
font-weight: 500;
}
.mat-button-toggle-checked.mat-button-toggle-appearance-standard:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay {
opacity: .01;
}
}
}
}

404
ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts

@ -14,20 +14,32 @@
/// limitations under the License.
///
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { AdminSettings, MailServerSettings, smtpPortPattern } from '@shared/models/settings.models';
import { FormBuilder, FormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import {
AdminSettings,
MailConfigTemplate,
MailServerOauth2Provider,
MailServerSettings,
smtpPortPattern,
SmtpProtocol
} from '@shared/models/settings.models';
import { AdminService } from '@core/http/admin.service';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { TranslateService } from '@ngx-translate/core';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { isDefinedAndNotNull, isString } from '@core/utils';
import { Subject } from 'rxjs';
import { isDefined, isDefinedAndNotNull, isString } from '@core/utils';
import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { DomainSchema, domainSchemaTranslations, } from '@shared/models/oauth2.models';
import { WINDOW } from '@core/services/window.service';
import { AuthService } from '@core/auth/auth.service';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { MatChipInputEvent } from '@angular/material/chips';
@Component({
selector: 'tb-mail-server',
@ -35,40 +47,138 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./mail-server.component.scss', './settings-card.scss']
})
export class MailServerComponent extends PageComponent implements OnInit, OnDestroy, HasConfirmForm {
mailSettings: UntypedFormGroup;
adminSettings: AdminSettings<MailServerSettings>;
smtpProtocols = ['smtp', 'smtps'];
smtpProtocols = Object.values(SmtpProtocol);
showChangePassword = false;
protocols = Object.values(DomainSchema).filter(value => value !== DomainSchema.MIXED);
domainSchemaTranslations = domainSchemaTranslations;
mailServerOauth2Provider = MailServerOauth2Provider;
tlsVersions = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'];
helpLink: string;
templates = new Map<string, MailConfigTemplate>();
templateProvider = ['CUSTOM'];
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
private destroy$ = new Subject<void>();
private DOMAIN_AND_PORT_REGEXP = /^(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?$/;
private URL_REGEXP = /^[A-Za-z][A-Za-z\d.+-]*:\/*(?:\w+(?::\w+)?@)?[^\s/]+(?::\d+)?(?:\/[\w#!:.,?+=&%@\-/]*)?$/;
private loginProcessingUrl: string;
mailSettings = this.fb.group({
mailFrom: ['', [Validators.required]],
smtpProtocol: [SmtpProtocol.SMTP],
smtpHost: ['localhost', [Validators.required]],
smtpPort: [25, [Validators.required,
Validators.pattern(smtpPortPattern),
Validators.maxLength(5)]],
timeout: [10000, [Validators.required,
Validators.pattern(/^[0-9]{1,6}$/),
Validators.maxLength(6)]],
enableTls: [false],
tlsVersion: [{ value: null, disabled: true }],
enableProxy: [false],
proxyHost: [{ value: '', disabled: true }, [Validators.required]],
proxyPort: [{ value: null, disabled: true }, [Validators.required, Validators.min(1), Validators.max(65535)]],
proxyUser: [{ value: '', disabled: true }],
proxyPassword: [{ value: '', disabled: true }],
username: [''],
changePassword: [false],
password: [''],
enableOauth2: [false],
providerId: ['CUSTOM', [Validators.required]],
clientId: [{ value:'', disabled: true }, [Validators.required, Validators.maxLength(255)]],
clientSecret: [{ value:'', disabled: true }, [Validators.required, Validators.maxLength(2048)]],
providerTenantId: [{value: '', disabled: true}, [Validators.required]],
authUri: [{value: '', disabled: true}, [Validators.required, Validators.pattern(this.URL_REGEXP)]],
tokenUri: [{value: '', disabled: true}, [Validators.required, Validators.pattern(this.URL_REGEXP)]],
scope: [],
redirectUri: [{ value:'', disabled: true}]
});
private defaultConfiguration = {
providerId: 'CUSTOM',
smtpProtocol: SmtpProtocol.SMTP,
smtpHost: '',
smtpPort: null,
timeout: null,
enableTls: false,
tlsVersion: null,
enableProxy: false,
proxyHost: '',
proxyPort: null,
proxyUser: '',
proxyPassword: '',
enableOauth2: false,
clientId: '',
clientSecret: '',
providerTenantId: '',
authUri: '',
tokenUri: '',
scope: [],
redirectUri: ''
};
domainForm = this.fb.group({
name: [this.window.location.hostname, [
Validators.required, Validators.maxLength(255),
Validators.pattern(this.DOMAIN_AND_PORT_REGEXP)]
],
scheme: [DomainSchema.HTTPS, Validators.required]
});
constructor(protected store: Store<AppState>,
private router: Router,
private route: ActivatedRoute,
private adminService: AdminService,
private authService: AuthService,
private translate: TranslateService,
public fb: UntypedFormBuilder) {
public fb: FormBuilder,
@Inject(WINDOW) private window: Window) {
super(store);
}
ngOnInit() {
this.buildMailServerSettingsForm();
this.adminService.getAdminSettings<MailServerSettings>('mail').subscribe(
(adminSettings) => {
this.adminSettings = adminSettings;
if (this.adminSettings.jsonValue && isString(this.adminSettings.jsonValue.enableTls)) {
this.adminSettings.jsonValue.enableTls = (this.adminSettings.jsonValue.enableTls as any) === 'true';
}
this.showChangePassword =
isDefinedAndNotNull(this.adminSettings.jsonValue.showChangePassword) ? this.adminSettings.jsonValue.showChangePassword : true ;
delete this.adminSettings.jsonValue.showChangePassword;
this.mailSettings.reset(this.adminSettings.jsonValue);
this.enableMailPassword(!this.showChangePassword);
this.enableProxyChanged();
this.mailServerSettingsForm();
this.domainFormConfiguration();
forkJoin([
this.adminService.getLoginProcessingUrl(),
this.adminService.getMailConfigTemplate(),
this.adminService.getAdminSettings<MailServerSettings>('mail')
]).subscribe(([loginProcessingUrl, mailConfigTemplate, adminSettings]) => {
this.loginProcessingUrl = loginProcessingUrl;
this.initTemplates(mailConfigTemplate);
this.adminSettings = adminSettings;
if (this.adminSettings.jsonValue && isString(this.adminSettings.jsonValue.enableTls)) {
this.adminSettings.jsonValue.enableTls = (this.adminSettings.jsonValue.enableTls as any) === 'true';
}
);
this.showChangePassword = isDefinedAndNotNull(this.adminSettings.jsonValue.showChangePassword)
? this.adminSettings.jsonValue.showChangePassword : true;
delete this.adminSettings.jsonValue.showChangePassword;
if (!this.adminSettings.jsonValue.providerId) {
this.adminSettings.jsonValue.providerId = 'CUSTOM';
}
this.mailSettings.reset(this.adminSettings.jsonValue, {emitEvent: false});
this.enableMailPassword(!this.showChangePassword);
this.enableProxyChanged();
this.enableTls(this.adminSettings.jsonValue.enableTls);
this.helpLink = this.templates.get(this.adminSettings.jsonValue.providerId)?.helpLink || null;
if (this.adminSettings.jsonValue.enableOauth2) {
this.enableOauth2(!!this.adminSettings.jsonValue.enableOauth2);
this.enableProviderTenantIdChanged(this.adminSettings.jsonValue.providerId);
this.parseUrl(this.adminSettings.jsonValue.redirectUri);
this.mailSettings.get('redirectUri').patchValue(this.adminSettings.jsonValue.redirectUri, {emitEvent: false});
} else {
this.mailSettings.get('enableOauth2').patchValue(false, {emitEvent: false});
}
});
}
ngOnDestroy() {
@ -77,56 +187,153 @@ export class MailServerComponent extends PageComponent implements OnInit, OnDest
super.ngOnDestroy();
}
buildMailServerSettingsForm() {
this.mailSettings = this.fb.group({
mailFrom: ['', [Validators.required]],
smtpProtocol: ['smtp'],
smtpHost: ['localhost', [Validators.required]],
smtpPort: ['25', [Validators.required,
Validators.pattern(smtpPortPattern),
Validators.maxLength(5)]],
timeout: ['10000', [Validators.required,
Validators.pattern(/^[0-9]{1,6}$/),
Validators.maxLength(6)]],
enableTls: [false],
tlsVersion: [],
enableProxy: [false, []],
proxyHost: ['', [Validators.required]],
proxyPort: ['', [Validators.required, Validators.min(1), Validators.max(65535)]],
proxyUser: [''],
proxyPassword: [''],
username: [''],
changePassword: [false],
password: ['']
private initTemplates(templates): void {
templates.map(provider => {
delete provider.additionalInfo;
this.templates.set(provider.providerId, provider);
});
this.templateProvider.push(...Array.from(this.templates.keys()));
this.templateProvider.sort();
}
private mailServerSettingsForm(): void {
this.registerDisableOnLoadFormControl(this.mailSettings.get('smtpProtocol'));
this.registerDisableOnLoadFormControl(this.mailSettings.get('enableTls'));
this.registerDisableOnLoadFormControl(this.mailSettings.get('enableProxy'));
this.registerDisableOnLoadFormControl(this.mailSettings.get('changePassword'));
this.mailSettings.get('enableTls').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(value => this.enableTls(value));
this.mailSettings.get('enableProxy').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.enableProxyChanged();
});
this.mailSettings.get('changePassword').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe((value) => {
this.enableMailPassword(value);
});
this.mailSettings.get('enableOauth2').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe( value => {
this.enableOauth2(value);
this.enableProviderTenantIdChanged(this.mailSettings.get('providerId').value);
if (value && !this.mailSettings.get('redirectUri').value) {
this.mailSettings.get('redirectUri').patchValue(this.redirectURI(), {emitEvent: false});
}
});
this.mailSettings.get('providerTenantId').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(tenantId => {
const authorizationUri = this.templates.get(this.mailServerOauth2Provider.OFFICE_365).authorizationUri.replace('%s', `${tenantId}`);
const accessTokenUri = this.templates.get(this.mailServerOauth2Provider.OFFICE_365).accessTokenUri.replace('%s', `${tenantId}`);
this.mailSettings.get('authUri').patchValue(authorizationUri, {emitEvent: false});
this.mailSettings.get('tokenUri').patchValue(accessTokenUri, {emitEvent: false});
});
this.mailSettings.get('providerId').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe( value => {
if (value === this.mailServerOauth2Provider.CUSTOM || !value) {
this.mailSettings.reset({...this.adminSettings.jsonValue, ...this.defaultConfiguration}, {emitEvent: false});
} else {
const config = this.templates.get(value);
this.helpLink = config.helpLink;
this.mailSettings.patchValue({
smtpProtocol: SmtpProtocol[config.smtpProtocol],
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
timeout: config.timeout,
enableTls: config.enableTls,
tlsVersion: config.tlsVersion,
authUri: config.authorizationUri,
tokenUri: config.accessTokenUri,
scope: config.scope,
enableOauth2: false,
enableProxy: false,
proxyHost: '',
proxyPort: null,
proxyUser: '',
proxyPassword: '',
clientId: '',
clientSecret: '',
providerTenantId: '',
redirectUri: ''
}, {emitEvent: false});
}
this.enableTls(this.mailSettings.get('enableTls').value);
this.enableOauth2(this.mailSettings.get('enableOauth2').value);
this.enableProviderTenantIdChanged(value);
});
}
private domainFormConfiguration(): void {
this.domainForm.get('name').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(
value => this.mailSettings.get('redirectUri').patchValue(
this.redirectURI(this.domainForm.get('scheme').value, value),
{emitEvent: false}
)
);
this.domainForm.get('scheme').valueChanges.pipe(
takeUntil(this.destroy$)
).subscribe(
(value) => this.mailSettings.get('redirectUri').patchValue(this.redirectURI(value), {emitEvent: false})
);
}
enableProxyChanged(): void {
private enableOauth2(value: boolean): void {
if (value) {
this.mailSettings.get('clientId').enable({emitEvent: false});
this.mailSettings.get('clientSecret').enable({emitEvent: false});
this.mailSettings.get('redirectUri').enable({emitEvent: false});
if (this.mailSettings.get('providerId').value === this.mailServerOauth2Provider.CUSTOM) {
this.mailSettings.get('authUri').enable({emitEvent: false});
this.mailSettings.get('tokenUri').enable({emitEvent: false});
} else {
this.mailSettings.get('authUri').disable({emitEvent: false});
this.mailSettings.get('tokenUri').disable({emitEvent: false});
}
} else {
this.mailSettings.get('clientId').disable({emitEvent: false});
this.mailSettings.get('clientSecret').disable({emitEvent: false});
this.mailSettings.get('redirectUri').disable({emitEvent: false});
this.mailSettings.get('authUri').disable({emitEvent: false});
this.mailSettings.get('tokenUri').disable({emitEvent: false});
}
}
private enableProviderTenantIdChanged(value: string): void {
if (value === this.mailServerOauth2Provider.OFFICE_365 && this.mailSettings.get('enableOauth2').value) {
this.mailSettings.get('providerTenantId').enable({emitEvent: false});
} else {
this.mailSettings.get('providerTenantId').disable({emitEvent: false});
}
}
private enableProxyChanged(): void {
const enableProxy: boolean = this.mailSettings.get('enableProxy').value;
if (enableProxy) {
this.mailSettings.get('proxyHost').enable();
this.mailSettings.get('proxyPort').enable();
this.mailSettings.get('proxyHost').enable({emitEvent: false});
this.mailSettings.get('proxyPort').enable({emitEvent: false});
this.mailSettings.get('proxyUser').enable({emitEvent: false});
this.mailSettings.get('proxyPassword').enable({emitEvent: false});
} else {
this.mailSettings.get('proxyHost').disable();
this.mailSettings.get('proxyPort').disable();
this.mailSettings.get('proxyHost').disable({emitEvent: false});
this.mailSettings.get('proxyPort').disable({emitEvent: false});
this.mailSettings.get('proxyUser').disable({emitEvent: false});
this.mailSettings.get('proxyPassword').disable({emitEvent: false});
}
}
enableMailPassword(enable: boolean) {
private enableMailPassword(enable: boolean) {
if (enable) {
this.mailSettings.get('password').enable({emitEvent: false});
} else {
@ -134,14 +341,21 @@ export class MailServerComponent extends PageComponent implements OnInit, OnDest
}
}
private enableTls(enable: boolean): void {
if (enable) {
this.mailSettings.get('tlsVersion').enable({emitEvent: false});
} else {
this.mailSettings.get('tlsVersion').disable({emitEvent: false});
}
}
sendTestMail(): void {
this.adminSettings.jsonValue = {...this.adminSettings.jsonValue, ...this.mailSettingsFormValue};
this.adminService.sendTestMail(this.adminSettings).subscribe(
() => {
this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('admin.test-mail-sent'),
type: 'success' }));
}
);
this.adminService.sendTestMail(this.adminSettings).subscribe({
next: () => this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('admin.test-mail-sent'),
type: 'success' })),
error: error => this.store.dispatch(new ActionNotificationShow({message: error.error.message, type: 'error'}))
});
}
save(): void {
@ -150,18 +364,88 @@ export class MailServerComponent extends PageComponent implements OnInit, OnDest
(adminSettings) => {
this.adminSettings = adminSettings;
this.showChangePassword = true;
this.mailSettings.reset(this.adminSettings.jsonValue);
this.mailSettings.reset(this.adminSettings.jsonValue, {emitEvent: false});
this.domainForm.reset(this.domainForm.value);
this.parseUrl(this.adminSettings.jsonValue.redirectUri);
}
);
}
confirmForm(): UntypedFormGroup {
generateAccessToken(): void {
this.adminService.generateAccessToken().subscribe(
uri => this.window.location.href = uri
);
}
redirectURI(schema?: DomainSchema, name?: string): string {
const domainInfo = this.domainForm.value;
if (domainInfo.name !== '') {
const protocol = isDefined(schema) ? schema.toLowerCase() : domainInfo.scheme.toLowerCase();
const domainName = isDefined(name) ? name : domainInfo.name;
return `${protocol}://${domainName}${this.loginProcessingUrl}`;
}
return '';
}
private parseUrl(value: string): void {
if (value) {
const url = new URL(value);
this.domainForm.get('scheme').patchValue(
url.protocol.startsWith('https') ? DomainSchema.HTTPS : DomainSchema.HTTP, {emitEvent: false}
);
this.domainForm.get('name').patchValue(url.host, {emitEvent: false});
}
}
get accessTokenButtonName(): string {
return this.translate.instant(
this.adminSettings.jsonValue.tokenGenerated ? 'admin.oauth2.update-access-token' : 'admin.oauth2.generate-access-token'
);
}
get accessTokenStatus(): string {
return this.translate.instant(
this.adminSettings.jsonValue.tokenGenerated ? 'admin.oauth2.token-status-generated' : 'admin.oauth2.token-status-not-generated'
);
}
confirmForm(): FormGroup {
return this.mailSettings;
}
private get mailSettingsFormValue(): MailServerSettings {
const formValue = this.mailSettings.value;
const formValue = this.mailSettings.getRawValue() as Required<typeof this.mailSettings.value>;
delete formValue.changePassword;
return formValue;
}
trackByParams(index: number): number {
return index;
}
removeScope(i: number): void {
const controller = this.mailSettings.get('scope') as UntypedFormArray;
controller.removeAt(i);
controller.markAsTouched();
controller.markAsDirty();
}
addScope(event: MatChipInputEvent): void {
const input = event.chipInput.inputElement;
const value = event.value;
const controller = this.mailSettings.get('scope') as UntypedFormArray;
if ((value.trim() !== '')) {
controller.push(this.fb.control(value.trim()));
controller.markAsDirty();
}
if (input) {
input.value = '';
}
}
toggleEditMode(path: string): void {
this.mailSettings.get(path).disabled ? this.mailSettings.get(path).enable() : this.mailSettings.get(path).disable();
}
}

42
ui-ngx/src/app/shared/models/settings.models.ts

@ -17,6 +17,7 @@
import { ValidatorFn } from '@angular/forms';
import { isNotEmptyStr, isNumber } from '@core/utils';
import { VersionCreateConfig } from '@shared/models/vc.models';
import { HasUUID } from '@shared/models/id/has-uuid';
export const smtpPortPattern: RegExp = /^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/;
@ -25,16 +26,20 @@ export interface AdminSettings<T> {
jsonValue: T;
}
export declare type SmtpProtocol = 'smtp' | 'smtps';
export enum SmtpProtocol {
SMTP = 'smtp',
SMTPS = 'smtps'
}
export interface MailServerSettings {
showChangePassword: boolean;
showChangePassword?: boolean;
mailFrom: string;
smtpProtocol: SmtpProtocol;
smtpHost: string;
smtpPort: number;
timeout: number;
enableTls: boolean;
tlsVersion: string;
username: string;
changePassword?: boolean;
password?: string;
@ -43,6 +48,39 @@ export interface MailServerSettings {
proxyPort: number;
proxyUser: string;
proxyPassword: string;
enableOauth2: boolean;
providerId?: string;
clientId?: string;
clientSecret?: string;
providerTenantId?: string;
authUri?: string;
tokenUri?: string;
scope?: Array<string>;
redirectUri?: string;
tokenGenerated?: boolean;
}
export enum MailServerOauth2Provider {
OFFICE_365 = 'OFFICE_365',
CUSTOM = 'CUSTOM'
}
export interface MailConfigTemplate {
id: HasUUID;
createdTime: number;
name: string;
providerId: string;
helpLink: string;
scope: Array<string>;
accessTokenUri: string;
authorizationUri: string;
enableTls: boolean;
tlsVersion: string;
smtpProtocol: SmtpProtocol;
smtpHost: string;
smtpPort: number;
timeout: number;
additionalInfo: any;
}
export interface GeneralSettings {

24
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -174,6 +174,7 @@
"domain-name-unique": "Domain name and protocol need to unique.",
"domain-name-max-length": "Domain name should be less than 256",
"error-verification-url": "A domain name shouldn't contain symbols '/' and ':'. Example: thingsboard.io",
"connection-settings": "Connection settings",
"oauth2": {
"access-token-uri": "Access token URI",
"access-token-uri-required": "Access token URI is required.",
@ -264,7 +265,28 @@
"platform-android": "Android",
"platform-ios": "iOS",
"all-platforms": "All platforms",
"allowed-platforms": "Allowed platforms"
"smtp-provider": "SMTP provider",
"allowed-platforms": "Allowed platforms",
"authentication": "Authentication",
"basic": "Basic",
"provider": "Provider",
"redirect-url": "Redirect URI",
"domain-name": "Domain name",
"redirect-url-template": "Redirect URI template",
"microsoft-tenant-id": "Directory (tenant) Id",
"microsoft-tenant-id-required": "Directory (tenant) Id is required",
"token-uri": "Token URI",
"token-uri-required": "Token URI is required",
"redirect-uri": "Redirect URI",
"google-provider": "Google",
"microsoft-provider": "Office 365",
"sendgrid-provider": "Sendgrid",
"custom-provider": "Custom",
"generate-access-token": "Generate access token",
"update-access-token": "Update access token",
"access-token-status": "Access token status:",
"token-status-generated": "generated",
"token-status-not-generated": "not generated"
},
"smpp-provider": {
"smpp-version": "SMPP version",

Loading…
Cancel
Save