From 8a8dac38e9907f562a7ba7ff385368c3a303f4f3 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Fri, 17 Jul 2026 14:39:07 +0300 Subject: [PATCH 1/2] Delegate mail/SMS/notification sends from edge to cloud --- .../service/cloud/CloudContextComponent.java | 12 + .../service/cloud/event/UplinkMsgMapper.java | 3 + .../rpc/processor/MailCloudProcessor.java | 53 +++ .../SendNotificationCloudProcessor.java | 53 +++ .../rpc/processor/SmsCloudProcessor.java | 53 +++ .../update/DefaultDataUpdateService.java | 42 +++ .../service/mail/DefaultMailService.java | 349 +++++------------- .../server/service/mail/EdgeMailRequest.java | 80 ++++ .../mail/RefreshTokenExpCheckService.java | 66 +--- .../notification/EdgeNotificationRequest.java | 76 ++++ .../MobileAppNotificationChannel.java | 63 ++-- .../channels/SlackNotificationChannel.java | 33 +- .../server/service/sms/DefaultSmsService.java | 104 ++---- .../server/service/sms/EdgeSmsRequest.java | 51 +++ .../service/mail/DefaultMailServiceTest.java | 113 ++++++ .../service/sms/DefaultSmsServiceTest.java | 173 +++------ .../common/data/edge/EdgeEventActionType.java | 5 +- common/edge-api/src/main/proto/edge.proto | 21 ++ 18 files changed, 771 insertions(+), 579 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java b/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java index e973dbe8010..205aa057a97 100644 --- a/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java @@ -39,8 +39,11 @@ import org.thingsboard.server.service.cloud.rpc.processor.DeviceProfileCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.EdgeCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.EntityViewCloudProcessor; +import org.thingsboard.server.service.cloud.rpc.processor.MailCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.NotificationCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.OAuth2CloudProcessor; +import org.thingsboard.server.service.cloud.rpc.processor.SendNotificationCloudProcessor; +import org.thingsboard.server.service.cloud.rpc.processor.SmsCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.OtaPackageCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.QueueCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.RelationCloudProcessor; @@ -171,6 +174,15 @@ public CloudContextComponent(List processors) { @Autowired private AiModelCloudProcessor aiModelProcessor; + @Autowired + private MailCloudProcessor mailProcessor; + + @Autowired + private SmsCloudProcessor smsProcessor; + + @Autowired + private SendNotificationCloudProcessor sendNotificationProcessor; + // callback @Autowired private DbCallbackExecutorService dbCallbackExecutorService; diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/event/UplinkMsgMapper.java b/application/src/main/java/org/thingsboard/server/service/cloud/event/UplinkMsgMapper.java index 4a73264d47e..20bbbca8e2d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cloud/event/UplinkMsgMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/cloud/event/UplinkMsgMapper.java @@ -55,6 +55,9 @@ public UplinkMsg convertCloudEventToUplink(CloudEvent cloudEvent) { case RELATION_REQUEST -> cloudCtx.getRelationProcessor().convertRelationRequestEventToUplink(cloudEvent); case CALCULATED_FIELD_REQUEST -> cloudCtx.getCalculatedFieldProcessor().convertCalculatedFieldRequestEventToUplink(cloudEvent); case RPC_CALL -> cloudCtx.getDeviceProcessor().convertRpcCallEventToUplink(cloudEvent); + case SEND_EMAIL -> cloudCtx.getMailProcessor().convertSendEmailEventToUplink(cloudEvent); + case SEND_SMS -> cloudCtx.getSmsProcessor().convertSendSmsEventToUplink(cloudEvent); + case SEND_NOTIFICATION -> cloudCtx.getSendNotificationProcessor().convertSendNotificationEventToUplink(cloudEvent); default -> { log.warn("Unsupported action type [{}]", cloudEvent); yield null; diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java new file mode 100644 index 00000000000..c18b6d8be9a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 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.cloud.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.cloud.CloudEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.SendEmailUplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +/** + * Converts a SEND_EMAIL cloud event into a {@link SendEmailUplinkMsg}. The Edge does not render or + * transmit the mail itself; it forwards the serialized {@code EdgeMailRequest} (carried in the cloud + * event body) to the Cloud, which resolves the config, renders and sends via its own SMTP. + */ +@Slf4j +@Component +@TbCoreComponent +public class MailCloudProcessor { + + public UplinkMsg convertSendEmailEventToUplink(CloudEvent cloudEvent) { + log.trace("Executing convertSendEmailEventToUplink, cloudEvent [{}]", cloudEvent); + TenantId tenantId = cloudEvent.getTenantId(); + SendEmailUplinkMsg sendEmailUplinkMsg = SendEmailUplinkMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequest(JacksonUtil.toString(cloudEvent.getEntityBody())) + .build(); + + return UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addSendEmailUplinkMsg(sendEmailUplinkMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java new file mode 100644 index 00000000000..7f475f40ac6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 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.cloud.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.cloud.CloudEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.SendNotificationUplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +/** + * Converts a SEND_NOTIFICATION cloud event into a {@link SendNotificationUplinkMsg}. The Edge forwards the + * serialized {@code EdgeNotificationRequest} (carried in the cloud event body) to the Cloud, which resolves + * the channel credentials and delivers (Slack post / FCM push). + */ +@Slf4j +@Component +@TbCoreComponent +public class SendNotificationCloudProcessor { + + public UplinkMsg convertSendNotificationEventToUplink(CloudEvent cloudEvent) { + log.trace("Executing convertSendNotificationEventToUplink, cloudEvent [{}]", cloudEvent); + TenantId tenantId = cloudEvent.getTenantId(); + SendNotificationUplinkMsg sendNotificationUplinkMsg = SendNotificationUplinkMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequest(JacksonUtil.toString(cloudEvent.getEntityBody())) + .build(); + + return UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addSendNotificationUplinkMsg(sendNotificationUplinkMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java new file mode 100644 index 00000000000..c38d2399e46 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 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.cloud.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.cloud.CloudEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.SendSmsUplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +/** + * Converts a SEND_SMS cloud event into a {@link SendSmsUplinkMsg}. The Edge does not resolve or transmit + * the SMS itself; it forwards the serialized {@code EdgeSmsRequest} (carried in the cloud event body) to + * the Cloud, which resolves the config and sends via its own provider. + */ +@Slf4j +@Component +@TbCoreComponent +public class SmsCloudProcessor { + + public UplinkMsg convertSendSmsEventToUplink(CloudEvent cloudEvent) { + log.trace("Executing convertSendSmsEventToUplink, cloudEvent [{}]", cloudEvent); + TenantId tenantId = cloudEvent.getTenantId(); + SendSmsUplinkMsg sendSmsUplinkMsg = SendSmsUplinkMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequest(JacksonUtil.toString(cloudEvent.getEntityBody())) + .build(); + + return UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addSendSmsUplinkMsg(sendSmsUplinkMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java index 77d973eb7a2..1fa748b79f6 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java @@ -23,6 +23,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.edge.EdgeSettings; import org.thingsboard.server.common.data.id.RuleNodeId; @@ -33,17 +34,20 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.cloud.EdgeSettingsService; import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.component.RuleNodeClassInfo; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DbUpgradeExecutorService; +import org.thingsboard.server.service.install.SystemDataLoaderService; import org.thingsboard.server.service.install.lts.LtsMigrationService; import org.thingsboard.server.utils.TbNodeUpgradeUtils; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.concurrent.ExecutionException; @Service @@ -65,6 +69,8 @@ public class DefaultDataUpdateService implements DataUpdateService { private final TenantService tenantService; private final EdgeSettingsService edgeSettingsService; private final WidgetsBundleService widgetsBundleService; + private final AdminSettingsService adminSettingsService; + private final SystemDataLoaderService systemDataLoaderService; @Override public void updateData() throws Exception { @@ -79,9 +85,45 @@ public void updateData() throws Exception { // ... Edge-only + purgeAdminSettings(); log.info("Data updated."); } + private void purgeAdminSettings() throws Exception { + log.info("Purging admin settings"); + Set keep = Set.of("general", "connectivity"); + List scopes = new ArrayList<>(); + scopes.add(TenantId.SYS_TENANT_ID); + new PageDataIterable<>(tenantService::findTenantsIds, DEFAULT_PAGE_SIZE).forEach(scopes::add); + boolean systemJwtRemoved = false; + for (TenantId scope : scopes) { + List keysToDelete = new ArrayList<>(); + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData page; + do { + page = adminSettingsService.findAllByTenantId(scope, pageLink); + for (AdminSettings adminSettings : page.getData()) { + if (!keep.contains(adminSettings.getKey())) { + keysToDelete.add(adminSettings.getKey()); + } + } + pageLink = pageLink.nextPageLink(); + } while (page.hasNext()); + for (String key : keysToDelete) { + adminSettingsService.deleteAdminSettingsByTenantIdAndKey(scope, key); + if (TenantId.SYS_TENANT_ID.equals(scope) && "jwt".equals(key)) { + systemJwtRemoved = true; + } + } + if (!keysToDelete.isEmpty()) { + log.info("Purged {} admin settings for tenant [{}]: {}", keysToDelete.size(), scope, keysToDelete); + } + } + if (systemJwtRemoved) { + systemDataLoaderService.createRandomJwtSettings(); + } + } + @Override public void upgradeRuleNodes() { int totalRuleNodesUpgraded = 0; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index e19435eea6a..aad7656c8a8 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -17,90 +17,65 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; -import freemarker.template.Configuration; -import freemarker.template.Template; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.MessageSource; -import org.springframework.context.annotation.Lazy; import org.springframework.core.NestedRuntimeException; import org.springframework.core.io.InputStreamSource; import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; -import org.springframework.ui.freemarker.FreeMarkerTemplateUtils; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.TbEmail; import org.thingsboard.server.cache.limits.RateLimitService; -import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiFeature; -import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageRecordState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.exception.RateLimitExceededException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; -import org.thingsboard.server.common.stats.TbApiUsageReportClient; -import org.thingsboard.server.dao.exception.IncorrectParameterException; -import org.thingsboard.server.dao.settings.AdminSettingsService; -import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.dao.cloud.CloudEventService; import java.io.ByteArrayInputStream; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +/** + * On the Edge the MailService is a thin client. Any send that would rely on admin-configured (tenant or + * system) mail settings cannot be resolved on the Edge, because the mail settings are no longer synced to + * the Edge. Instead of resolving config, rendering templates and opening SMTP locally, the Edge packages + * the call into an {@link EdgeMailRequest} and enqueues a SEND_EMAIL cloud event; the Cloud resolves the + * config, renders and transmits via its own SMTP. The only local send that remains is the rule-node + * "own SMTP" path, where the caller supplies a fully-configured {@link JavaMailSender} with plaintext + * credentials (no admin settings involved). + */ @Slf4j @Service @RequiredArgsConstructor public class DefaultMailService implements MailService { - private static final String TARGET_EMAIL = "targetEmail"; - private static final String UTF_8 = "UTF-8"; - private static final long DEFAULT_TIMEOUT = 10_000; - private final ScheduledExecutorService timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); - private final MessageSource messages; - private final Configuration freemarkerConfig; - private final AdminSettingsService adminSettingsService; - private final TbApiUsageReportClient apiUsageClient; - @Lazy - private final TbApiUsageStateService apiUsageStateService; private final MailSenderInternalExecutorService mailExecutorService; private final PasswordResetExecutorService passwordResetExecutorService; - private final TbMailContextComponent ctx; private final RateLimitService rateLimitService; + private final CloudEventService cloudEventService; @Value("${mail.per_tenant_rate_limits:}") private String perTenantRateLimitConfig; - private TbMailSender mailSender; - - private String mailFrom; - - private long timeout; - - @PostConstruct - private void init() { - // edge-only: merge comment - // updateMailConfiguration(); - } - @PreDestroy public void destroy() { timeoutScheduler.shutdownNow(); @@ -108,78 +83,42 @@ public void destroy() { @Override public void updateMailConfiguration() { - AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); - if (settings != null) { - JsonNode jsonConfig = settings.getJsonValue(); - mailSender = new TbMailSender(ctx, jsonConfig); - mailFrom = jsonConfig.get("mailFrom").asText(); - timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); - } else { - throw new IncorrectParameterException("Failed to update mail configuration. Settings not found!"); - } + // Mail is sent from the Cloud on the Edge; there is no local mail configuration to update. } @Override public void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException { - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(tenantId, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.SEND_BASIC) + .to(email).subject(subject).message(message).build()); } @Override public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException { - TbMailSender testMailSender = new TbMailSender(ctx, 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); - - Map model = new HashMap<>(); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("test.ftl", model); - - sendMail(testMailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.TEST_MAIL) + .testConfig(jsonConfig).to(email).build()); } @Override public void sendActivationEmail(String activationLink, long ttlMs, String email) throws ThingsboardException { - String subject = messages.getMessage("activation.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("activationLink", activationLink); - model.put("activationLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("activation.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.ACTIVATION) + .activationLink(activationLink).ttlMs(ttlMs).to(email).build()); } @Override public void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException { - - String subject = messages.getMessage("account.activated.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("loginLink", loginLink); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("account.activated.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.ACCOUNT_ACTIVATED) + .loginLink(loginLink).to(email).build()); } @Override public void sendResetPasswordEmail(String passwordResetLink, long ttlMs, String email) throws ThingsboardException { - - String subject = messages.getMessage("reset.password.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("passwordResetLink", passwordResetLink); - model.put("passwordResetLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("reset.password.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.RESET_PASSWORD) + .passwordResetLink(passwordResetLink).ttlMs(ttlMs).to(email).build()); } @Override @@ -195,194 +134,93 @@ public void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, St @Override public void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException { - - String subject = messages.getMessage("password.was.reset.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("loginLink", loginLink); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("password.was.reset.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.PASSWORD_WAS_RESET) + .loginLink(loginLink).to(email).build()); } @Override - public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException { - sendMail(tenantId, customerId, tbEmail, this.mailSender, timeout); + public void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException { + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.ACCOUNT_LOCKOUT) + .lockoutEmail(lockoutEmail).to(email).maxFailedLoginAttempts(maxFailedLoginAttempts).build()); } @Override - public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { - sendMail(tenantId, customerId, tbEmail, javaMailSender, timeout); - } - - private void sendMail(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { - if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) { - if (tenantId != null && !tenantId.isSysTenantId() && StringUtils.isNotEmpty(perTenantRateLimitConfig) && - !rateLimitService.checkRateLimit(LimitedApi.EMAILS, (Object) tenantId, perTenantRateLimitConfig)) { - throw new RateLimitExceededException(LimitedApi.EMAILS); - } - try { - MimeMessage mailMsg = javaMailSender.createMimeMessage(); - boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty()); - MimeMessageHelper helper = new MimeMessageHelper(mailMsg, multipart, "UTF-8"); - helper.setFrom(StringUtils.isBlank(tbEmail.getFrom()) ? mailFrom : tbEmail.getFrom()); - helper.setTo(tbEmail.getTo().split("\\s*,\\s*")); - if (!StringUtils.isBlank(tbEmail.getCc())) { - helper.setCc(tbEmail.getCc().split("\\s*,\\s*")); - } - if (!StringUtils.isBlank(tbEmail.getBcc())) { - helper.setBcc(tbEmail.getBcc().split("\\s*,\\s*")); - } - helper.setSubject(tbEmail.getSubject()); - helper.setText(tbEmail.getBody(), tbEmail.isHtml()); - - if (multipart) { - for (String imgId : tbEmail.getImages().keySet()) { - String imgValue = tbEmail.getImages().get(imgId); - String value = imgValue.replaceFirst("^data:image/[^;]*;base64,?", ""); - byte[] bytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(value); - String contentType = helper.getFileTypeMap().getContentType(imgId); - InputStreamSource iss = () -> new ByteArrayInputStream(bytes); - helper.addInline(imgId, iss, contentType); - } - } - sendMailWithTimeout(javaMailSender, helper.getMimeMessage(), timeout); - apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.EMAIL_EXEC_COUNT, 1); - } catch (Exception e) { - throw handleException(e); - } - } else { - throw new RuntimeException("Email sending is disabled due to API limits!"); - } + public void sendTwoFaVerificationEmail(String email, String verificationCode, int expirationTimeSeconds) throws ThingsboardException { + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.TWO_FA) + .to(email).verificationCode(verificationCode).expirationTimeSeconds(expirationTimeSeconds).build()); } @Override - public void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException { - String subject = messages.getMessage("account.lockout.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("lockoutAccount", lockoutEmail); - model.put("maxFailedLoginAttempts", maxFailedLoginAttempts); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("account.lockout.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageRecordState recordState) throws ThingsboardException { + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.API_USAGE_STATE) + .apiFeature(apiFeature).stateValue(stateValue).to(email).recordState(recordState).build()); } @Override - public void sendTwoFaVerificationEmail(String email, String verificationCode, int expirationTimeSeconds) throws ThingsboardException { - String subject = messages.getMessage("2fa.verification.code.subject", null, Locale.US); - String message = mergeTemplateIntoString("2fa.verification.code.ftl", Map.of( - TARGET_EMAIL, email, - "code", verificationCode, - "expirationTimeSeconds", expirationTimeSeconds - )); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException { + enqueue(tenantId, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.SEND_TB_EMAIL) + .tbEmail(tbEmail).build()); } @Override - public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageRecordState recordState) throws ThingsboardException { - String subject = messages.getMessage("api.usage.state", null, Locale.US); - - Map model = new HashMap<>(); - model.put("apiFeature", apiFeature.getLabel()); - model.put(TARGET_EMAIL, email); - - String message = switch (stateValue) { - case ENABLED -> { - model.put("apiLabel", toEnabledValueLabel(apiFeature)); - yield mergeTemplateIntoString("state.enabled.ftl", model); + public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { + // Rule-node "own SMTP" path: the caller supplies a fully-configured sender with plaintext + // credentials, so this is sent locally on the Edge without any admin config resolution. + if (tenantId != null && !tenantId.isSysTenantId() && StringUtils.isNotEmpty(perTenantRateLimitConfig) && + !rateLimitService.checkRateLimit(LimitedApi.EMAILS, (Object) tenantId, perTenantRateLimitConfig)) { + throw new RateLimitExceededException(LimitedApi.EMAILS); + } + try { + MimeMessage mailMsg = javaMailSender.createMimeMessage(); + boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty()); + MimeMessageHelper helper = new MimeMessageHelper(mailMsg, multipart, "UTF-8"); + helper.setFrom(tbEmail.getFrom()); + helper.setTo(tbEmail.getTo().split("\\s*,\\s*")); + if (!StringUtils.isBlank(tbEmail.getCc())) { + helper.setCc(tbEmail.getCc().split("\\s*,\\s*")); } - case WARNING -> { - model.put("apiValueLabel", toDisabledValueLabel(apiFeature) + " " + toWarningValueLabel(recordState)); - yield mergeTemplateIntoString("state.warning.ftl", model); + if (!StringUtils.isBlank(tbEmail.getBcc())) { + helper.setBcc(tbEmail.getBcc().split("\\s*,\\s*")); } - case DISABLED -> { - model.put("apiLimitValueLabel", toDisabledValueLabel(apiFeature) + " " + toDisabledValueLabel(recordState)); - yield mergeTemplateIntoString("state.disabled.ftl", model); + helper.setSubject(tbEmail.getSubject()); + helper.setText(tbEmail.getBody(), tbEmail.isHtml()); + + if (multipart) { + for (String imgId : tbEmail.getImages().keySet()) { + String imgValue = tbEmail.getImages().get(imgId); + String value = imgValue.replaceFirst("^data:image/[^;]*;base64,?", ""); + byte[] bytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(value); + String contentType = helper.getFileTypeMap().getContentType(imgId); + InputStreamSource iss = () -> new ByteArrayInputStream(bytes); + helper.addInline(imgId, iss, contentType); + } } - }; - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + sendMailWithTimeout(javaMailSender, helper.getMimeMessage(), timeout); + } catch (Exception e) { + throw handleException(e); + } } @Override public void testConnection(TenantId tenantId) throws Exception { - mailSender.testConnection(); + // Mail is sent from the Cloud on the Edge; there is no local SMTP connection to test. } @Override public boolean isConfigured(TenantId tenantId) { - return mailSender != null; - } - - private String toEnabledValueLabel(ApiFeature apiFeature) { - return switch (apiFeature) { - case DB -> "save"; - case TRANSPORT -> "receive"; - case JS -> "invoke"; - case RE -> "process"; - case EMAIL, SMS -> "send"; - case ALARM -> "create"; - default -> throw new RuntimeException("Not implemented!"); - }; - } - - private String toDisabledValueLabel(ApiFeature apiFeature) { - return switch (apiFeature) { - case DB -> "saved"; - case TRANSPORT -> "received"; - case JS -> "invoked"; - case RE -> "processed"; - case EMAIL, SMS -> "sent"; - case ALARM -> "created"; - default -> throw new RuntimeException("Not implemented!"); - }; - } - - private String toWarningValueLabel(ApiUsageRecordState recordState) { - String valueInM = recordState.getValueAsString(); - String thresholdInM = recordState.getThresholdAsString(); - return switch (recordState.getKey()) { - case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> valueInM + " out of " + thresholdInM + " allowed data points"; - case TRANSPORT_MSG_COUNT -> valueInM + " out of " + thresholdInM + " allowed messages"; - case JS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; - case TBEL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Tbel functions"; - case RE_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; - case EMAIL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Email messages"; - case SMS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed SMS messages"; - default -> throw new RuntimeException("Not implemented!"); - }; - } - - private String toDisabledValueLabel(ApiUsageRecordState recordState) { - return switch (recordState.getKey()) { - case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> recordState.getValueAsString() + " data points"; - case TRANSPORT_MSG_COUNT -> recordState.getValueAsString() + " messages"; - case JS_EXEC_COUNT -> "JavaScript functions " + recordState.getValueAsString() + " times"; - case TBEL_EXEC_COUNT -> "TBEL functions " + recordState.getValueAsString() + " times"; - case RE_EXEC_COUNT -> recordState.getValueAsString() + " Rule Engine messages"; - case EMAIL_EXEC_COUNT -> recordState.getValueAsString() + " Email messages"; - case SMS_EXEC_COUNT -> recordState.getValueAsString() + " SMS messages"; - default -> throw new RuntimeException("Not implemented!"); - }; + // Mail sending is delegated to the Cloud, which owns the configuration. + return true; } - private void sendMail(JavaMailSenderImpl mailSender, String mailFrom, String email, - String subject, String message, long timeout) throws ThingsboardException { + private void enqueue(TenantId tenantId, EdgeMailRequest request) throws ThingsboardException { try { - MimeMessage mimeMsg = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMsg, UTF_8); - helper.setFrom(mailFrom); - helper.setTo(email); - helper.setSubject(subject); - helper.setText(message, true); - - sendMailWithTimeout(mailSender, helper.getMimeMessage(), timeout); + cloudEventService.saveCloudEvent(tenantId, CloudEventType.TENANT, EdgeEventActionType.SEND_EMAIL, + tenantId, JacksonUtil.valueToTree(request)); } catch (Exception e) { throw handleException(e); } @@ -401,17 +239,6 @@ private void sendMailWithTimeout(JavaMailSender mailSender, MimeMessage msg, lon } } - private String mergeTemplateIntoString(String templateLocation, - Map model) throws ThingsboardException { - try { - Template template = freemarkerConfig.getTemplate(templateLocation); - return FreeMarkerTemplateUtils.processTemplateIntoString(template, model); - } catch (Exception e) { - log.warn("Failed to process mail template: {}", ExceptionUtils.getRootCauseMessage(e)); - throw new ThingsboardException("Failed to process mail template: " + e.getMessage(), e, ThingsboardErrorCode.GENERAL); - } - } - protected ThingsboardException handleException(Throwable exception) { if (exception instanceof ThingsboardException thingsboardException) { return thingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java b/application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java new file mode 100644 index 00000000000..733f824b0ef --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2026 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.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.rule.engine.api.TbEmail; +import org.thingsboard.server.common.data.ApiFeature; +import org.thingsboard.server.common.data.ApiUsageRecordState; +import org.thingsboard.server.common.data.ApiUsageStateValue; + +/** + * Describes a mail send that the Edge delegates to the Cloud. On the Edge, a mail send that depends + * on admin-configured (tenant/system) mail settings is packaged into this request and enqueued as a + * SEND_EMAIL cloud event. On the Cloud, {@code method} selects the matching {@code MailService} call + * so the Cloud resolves the config, renders the template and transmits via its own SMTP. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EdgeMailRequest { + + public enum MailMethod { + SEND_BASIC, // sendEmail(to, subject, message) + SEND_TB_EMAIL, // send(TbEmail) + ACTIVATION, // sendActivationEmail(activationLink, ttlMs, to) + ACCOUNT_ACTIVATED, // sendAccountActivatedEmail(loginLink, to) + RESET_PASSWORD, // sendResetPasswordEmail(passwordResetLink, ttlMs, to) + PASSWORD_WAS_RESET,// sendPasswordWasResetEmail(loginLink, to) + TWO_FA, // sendTwoFaVerificationEmail(to, verificationCode, expirationTimeSeconds) + ACCOUNT_LOCKOUT, // sendAccountLockoutEmail(lockoutEmail, to, maxFailedLoginAttempts) + API_USAGE_STATE, // sendApiFeatureStateEmail(apiFeature, stateValue, to, recordState) + TEST_MAIL // sendTestMail(config, to) + } + + private MailMethod method; + + private String to; + private String subject; + private String message; + + private TbEmail tbEmail; + + private String activationLink; + private String loginLink; + private String passwordResetLink; + private Long ttlMs; + + private String verificationCode; + private Integer expirationTimeSeconds; + + private String lockoutEmail; + private Integer maxFailedLoginAttempts; + + private ApiFeature apiFeature; + private ApiUsageStateValue stateValue; + private ApiUsageRecordState recordState; + + private JsonNode testConfig; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java b/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java index 03cb60a8c23..d23024184d4 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java @@ -15,76 +15,22 @@ */ 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 java.util.concurrent.TimeUnit; - -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}", - timeUnit = TimeUnit.SECONDS) - 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("refreshToken") - && jsonValue.has("refreshTokenExpires")) { - try { - long expiresIn = jsonValue.get("refreshTokenExpires").longValue(); - long tokenLifeDuration = expiresIn - System.currentTimeMillis(); - if (tokenLifeDuration < 0) { - ((ObjectNode) jsonValue).put("tokenGenerated", false); - ((ObjectNode) jsonValue).remove("refreshToken"); - ((ObjectNode) jsonValue).remove("refreshTokenExpires"); - - adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, settings); - } else if (tokenLifeDuration < 604800000L) { //less than 7 days - log.info("Trying to refresh refresh token."); + public static final int AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS = 90; - String clientId = jsonValue.get("clientId").asText(); - String clientSecret = jsonValue.get("clientSecret").asText(); - String refreshToken = jsonValue.get("refreshToken").asText(); - String tokenUri = jsonValue.get("tokenUri").asText(); + // Disabled on the Edge. The Edge is a thin mail client: the Cloud sends all mail and owns + // refreshing the mail OAuth2 (Office 365) refresh token. Mail settings are no longer synced to the Edge, + // and refreshing the token here too would race the Cloud and could invalidate the single-use refresh + // token. Token refresh runs on the Cloud only. - 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); - } - } catch (Exception e) { - log.error("Error occurred while checking token", e); - } - } - } - } -} \ No newline at end of file +} diff --git a/application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java b/application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java new file mode 100644 index 00000000000..b135c574e84 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2026 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.notification; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Describes a notification delivery that the Edge delegates to the Cloud, for channels whose credentials + * live only on the Cloud (they are stored in the {@code notifications} admin settings that no longer sync + * to the Edge). {@code method} selects the channel: + *
    + *
  • {@code SEND_SLACK} - the Cloud resolves the Slack bot token and posts {@code message} (plus any + * {@code files}) to {@code conversationId}. The whole send is delegated.
  • + *
  • {@code SEND_MOBILE_PUSH} - the Edge keeps the notification record, device-token lookup and unread + * count locally and delegates only the FCM push: the Cloud resolves the Firebase credentials and pushes + * the already-built payload ({@code subject}/{@code body}/{@code data}/{@code badge}) to every + * {@code fcmToken}.
  • + *
+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EdgeNotificationRequest { + + public enum NotificationMethod { + SEND_SLACK, + SEND_MOBILE_PUSH + } + + private NotificationMethod method; + + // SEND_SLACK + private String conversationId; + private String message; + private List files; + + // SEND_MOBILE_PUSH + private Set fcmTokens; + private String subject; + private String body; + private Map data; + private Integer badge; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SlackFileData { + private String name; + private String type; + private byte[] data; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java index 1c61999a9d9..3cf3e49eaf8 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java @@ -18,45 +18,47 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.MessagingErrorCode; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.api.notification.FirebaseService; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.notification.info.NotificationInfo; -import org.thingsboard.server.common.data.notification.settings.MobileAppNotificationDeliveryMethodConfig; -import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.cloud.CloudEventService; import org.thingsboard.server.dao.notification.NotificationService; -import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.notification.EdgeNotificationRequest; import org.thingsboard.server.service.notification.NotificationProcessingContext; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Optional; -import java.util.Set; import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.MOBILE_APP; +/** + * On the Edge the mobile-app channel keeps everything that is Edge-local - the notification record, the + * user's device-token lookup and the unread count - and delegates only the FCM push to the Cloud, because + * the Firebase credentials live only on the Cloud (in the {@code notifications} admin settings that no + * longer sync to the Edge). The already-built payload and the device tokens are enqueued as a + * SEND_NOTIFICATION cloud event; the Cloud resolves the credentials and pushes via FCM. Invalid-token + * pruning is not performed on the Edge for delegated pushes (the fire-and-forget uplink has no return path). + */ @Component @RequiredArgsConstructor -@Slf4j public class MobileAppNotificationChannel implements NotificationChannel { - private final FirebaseService firebaseService; private final UserService userService; private final NotificationService notificationService; - private final NotificationSettingsService notificationSettingsService; + private final CloudEventService cloudEventService; @Override public void sendNotification(User recipient, MobileAppDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception { @@ -92,31 +94,17 @@ public void sendNotification(User recipient, MobileAppDeliveryMethodNotification throw new IllegalArgumentException("User doesn't use the mobile app"); } - MobileAppNotificationDeliveryMethodConfig config = ctx.getDeliveryMethodConfig(MOBILE_APP); - String credentials = config.getFirebaseServiceAccountCredentials(); - Set validTokens = new HashSet<>(mobileSessions.keySet()); - - String subject = processedTemplate.getSubject(); - String body = processedTemplate.getBody(); - Map data = getNotificationData(processedTemplate, ctx); int unreadCount = notificationService.countUnreadNotificationsByRecipientId(ctx.getTenantId(), MOBILE_APP, recipient.getId()); - for (String token : mobileSessions.keySet()) { - try { - firebaseService.sendMessage(ctx.getTenantId(), credentials, token, subject, body, data, unreadCount); - } catch (FirebaseMessagingException e) { - MessagingErrorCode errorCode = e.getMessagingErrorCode(); - if (errorCode == MessagingErrorCode.UNREGISTERED || errorCode == MessagingErrorCode.INVALID_ARGUMENT || errorCode == MessagingErrorCode.SENDER_ID_MISMATCH) { - validTokens.remove(token); - userService.removeMobileSession(recipient.getTenantId(), token); - log.debug("[{}][{}] Removed invalid FCM token due to {} {} ({})", recipient.getTenantId(), recipient.getId(), errorCode, e.getMessage(), token); - continue; - } - throw new RuntimeException("Failed to send message via FCM: " + e.getMessage(), e); - } - } - if (validTokens.isEmpty()) { - throw new IllegalArgumentException("User doesn't use the mobile app"); - } + EdgeNotificationRequest edgeRequest = EdgeNotificationRequest.builder() + .method(EdgeNotificationRequest.NotificationMethod.SEND_MOBILE_PUSH) + .fcmTokens(new HashSet<>(mobileSessions.keySet())) + .subject(processedTemplate.getSubject()) + .body(processedTemplate.getBody()) + .data(getNotificationData(processedTemplate, ctx)) + .badge(unreadCount) + .build(); + cloudEventService.saveCloudEvent(ctx.getTenantId(), CloudEventType.TENANT, EdgeEventActionType.SEND_NOTIFICATION, + ctx.getTenantId(), JacksonUtil.valueToTree(edgeRequest)); } private Map getNotificationData(MobileAppDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) { @@ -146,10 +134,7 @@ private Map getNotificationData(MobileAppDeliveryMethodNotificat @Override public void check(TenantId tenantId) throws Exception { - NotificationSettings systemSettings = notificationSettingsService.findNotificationSettings(TenantId.SYS_TENANT_ID); - if (!systemSettings.getDeliveryMethodsConfigs().containsKey(MOBILE_APP)) { - throw new RuntimeException("Push-notifications to mobile are not configured"); - } + // Firebase credentials live on the Cloud; the Edge delegates the push, so nothing to verify locally. } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java index 2ac7a812622..55babe0cd45 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java @@ -17,35 +17,44 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.thingsboard.rule.engine.api.notification.SlackService; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import org.thingsboard.server.common.data.notification.settings.NotificationSettings; -import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; import org.thingsboard.server.common.data.notification.template.SlackDeliveryMethodNotificationTemplate; -import org.thingsboard.server.dao.notification.NotificationSettingsService; +import org.thingsboard.server.dao.cloud.CloudEventService; +import org.thingsboard.server.service.notification.EdgeNotificationRequest; import org.thingsboard.server.service.notification.NotificationProcessingContext; +/** + * On the Edge the Slack channel is a thin client. The Slack bot token lives only on the Cloud (in the + * {@code notifications} admin settings that no longer sync to the Edge), so the Edge packages the send into + * an {@link EdgeNotificationRequest} and enqueues a SEND_NOTIFICATION cloud event; the Cloud resolves the + * token and posts to Slack. + */ @Component @RequiredArgsConstructor public class SlackNotificationChannel implements NotificationChannel { - private final SlackService slackService; - private final NotificationSettingsService notificationSettingsService; + private final CloudEventService cloudEventService; @Override public void sendNotification(SlackConversation conversation, SlackDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception { - SlackNotificationDeliveryMethodConfig config = ctx.getDeliveryMethodConfig(NotificationDeliveryMethod.SLACK); - slackService.sendMessage(ctx.getTenantId(), config.getBotToken(), conversation.getId(), processedTemplate.getBody()); + EdgeNotificationRequest request = EdgeNotificationRequest.builder() + .method(EdgeNotificationRequest.NotificationMethod.SEND_SLACK) + .conversationId(conversation.getId()) + .message(processedTemplate.getBody()) + .build(); + + cloudEventService.saveCloudEvent(ctx.getTenantId(), CloudEventType.TENANT, EdgeEventActionType.SEND_NOTIFICATION, + ctx.getTenantId(), JacksonUtil.valueToTree(request)); } @Override public void check(TenantId tenantId) throws Exception { - NotificationSettings notificationSettings = notificationSettingsService.findNotificationSettings(tenantId); - if (!notificationSettings.getDeliveryMethodsConfigs().containsKey(NotificationDeliveryMethod.SLACK)) { - throw new RuntimeException("Slack API token is not configured"); - } + // Slack config lives on the Cloud; the Edge delegates the send, so nothing to verify locally. } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java index 233d9092c07..245ddad7420 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java @@ -15,118 +15,65 @@ */ package org.thingsboard.server.service.sms; -import com.fasterxml.jackson.databind.JsonNode; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedRuntimeException; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.SmsService; -import org.thingsboard.rule.engine.api.sms.SmsSender; -import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; -import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.sms.config.SmsProviderConfiguration; import org.thingsboard.server.common.data.sms.config.TestSmsRequest; -import org.thingsboard.server.common.stats.TbApiUsageReportClient; -import org.thingsboard.server.dao.settings.AdminSettingsService; -import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.dao.cloud.CloudEventService; +/** + * On the Edge the SmsService is a thin client. Any send that would rely on admin-configured (tenant or + * system) SMS provider settings cannot be resolved on the Edge, because the SMS settings are no longer + * synced to the Edge. Instead of resolving config and opening a provider connection locally, the Edge + * packages the call into an {@link EdgeSmsRequest} and enqueues a SEND_SMS cloud event; the Cloud resolves + * the config and transmits via its own provider. The rule-node "own plaintext config" path builds its own + * {@code SmsSender} directly and never goes through this service. + */ @Slf4j @Service @RequiredArgsConstructor public class DefaultSmsService implements SmsService { - private final SmsSenderFactory smsSenderFactory; - private final AdminSettingsService adminSettingsService; - private final TbApiUsageStateService apiUsageStateService; - private final TbApiUsageReportClient apiUsageClient; - - private SmsSender smsSender; - - @PostConstruct - private void init() { - updateSmsConfiguration(); - } - - @PreDestroy - private void destroy() { - if (this.smsSender != null) { - this.smsSender.destroy(); - } - } + private final CloudEventService cloudEventService; @Override public void updateSmsConfiguration() { - AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "sms"); - if (settings != null) { - try { - JsonNode jsonConfig = settings.getJsonValue(); - SmsProviderConfiguration configuration = JacksonUtil.convertValue(jsonConfig, SmsProviderConfiguration.class); - SmsSender newSmsSender = this.smsSenderFactory.createSmsSender(configuration); - if (this.smsSender != null) { - this.smsSender.destroy(); - } - this.smsSender = newSmsSender; - } catch (Exception e) { - log.error("Failed to create SMS sender", e); - } - } - } - - protected int sendSms(String numberTo, String message) throws ThingsboardException { - if (this.smsSender == null) { - throw new ThingsboardException("Unable to send SMS: no SMS provider configured!", ThingsboardErrorCode.GENERAL); - } - return this.sendSms(this.smsSender, numberTo, message); + // SMS is sent from the Cloud on the Edge; there is no local SMS configuration to update. } @Override public void sendSms(TenantId tenantId, CustomerId customerId, String[] numbersTo, String message) throws ThingsboardException { - if (apiUsageStateService.getApiUsageState(tenantId).isSmsSendEnabled()) { - int smsCount = 0; - try { - for (String numberTo : numbersTo) { - smsCount += this.sendSms(numberTo, message); - } - } finally { - if (smsCount > 0) { - apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.SMS_EXEC_COUNT, smsCount); - } - } - } else { - throw new RuntimeException("SMS sending is disabled due to API limits!"); - } + enqueue(tenantId, EdgeSmsRequest.builder() + .method(EdgeSmsRequest.SmsMethod.SEND_SMS) + .numbers(numbersTo).message(message).build()); } @Override public void sendTestSms(TestSmsRequest testSmsRequest) throws ThingsboardException { - SmsSender testSmsSender; - try { - testSmsSender = this.smsSenderFactory.createSmsSender(testSmsRequest.getProviderConfiguration()); - } catch (Exception e) { - throw handleException(e); - } - this.sendSms(testSmsSender, testSmsRequest.getNumberTo(), testSmsRequest.getMessage()); - testSmsSender.destroy(); + enqueue(TenantId.SYS_TENANT_ID, EdgeSmsRequest.builder() + .method(EdgeSmsRequest.SmsMethod.SEND_TEST_SMS) + .testSmsRequest(testSmsRequest).build()); } @Override public boolean isConfigured(TenantId tenantId) { - return smsSender != null; + // SMS sending is delegated to the Cloud, which owns the configuration. + return true; } - private int sendSms(SmsSender smsSender, String numberTo, String message) throws ThingsboardException { + private void enqueue(TenantId tenantId, EdgeSmsRequest request) throws ThingsboardException { try { - int sentSms = smsSender.sendSms(numberTo, message); - log.trace("Successfully sent sms to number: {}", numberTo); - return sentSms; + cloudEventService.saveCloudEvent(tenantId, CloudEventType.TENANT, EdgeEventActionType.SEND_SMS, + tenantId, JacksonUtil.valueToTree(request)); } catch (Exception e) { throw handleException(e); } @@ -140,8 +87,7 @@ private ThingsboardException handleException(Exception exception) { message = exception.getMessage(); } log.warn("Unable to send SMS: {}", message); - return new ThingsboardException(String.format("Unable to send SMS: %s", message), - ThingsboardErrorCode.GENERAL); + return new ThingsboardException(String.format("Unable to send SMS: %s", message), ThingsboardErrorCode.GENERAL); } } diff --git a/application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java b/application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java new file mode 100644 index 00000000000..649b8a5a856 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 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.sms; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.sms.config.TestSmsRequest; + +/** + * Describes an SMS send that the Edge delegates to the Cloud. On the Edge, a send that depends on + * admin-configured (tenant/system) SMS provider settings cannot be resolved locally, so the call is + * packaged into this request and enqueued as a SEND_SMS cloud event; on the Cloud, {@code method} + * selects the matching {@code SmsService} call so the Cloud resolves the config and transmits via its + * own provider. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EdgeSmsRequest { + + public enum SmsMethod { + SEND_SMS, // sendSms(customerId, numbersTo, message) + SEND_TEST_SMS // sendTestSms(testSmsRequest) + } + + private SmsMethod method; + + private String[] numbers; + private String message; + + private TestSmsRequest testSmsRequest; + +} diff --git a/application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java b/application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java new file mode 100644 index 00000000000..e830b48e021 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java @@ -0,0 +1,113 @@ +/** + * Copyright © 2016-2026 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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.TbEmail; +import org.thingsboard.server.cache.limits.RateLimitService; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.cloud.CloudEventService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class DefaultMailServiceTest { + + private final TenantId tenantId = new TenantId(UUID.randomUUID()); + + private CloudEventService cloudEventService; + private DefaultMailService mailService; + + @BeforeEach + void setUp() { + cloudEventService = mock(CloudEventService.class); + mailService = new DefaultMailService( + mock(MailSenderInternalExecutorService.class), + mock(PasswordResetExecutorService.class), + mock(RateLimitService.class), + cloudEventService); + } + + @AfterEach + void tearDown() { + mailService.destroy(); + } + + @Test + void sendActivationEmail_delegatesToCloud() throws Exception { + mailService.sendActivationEmail("http://activate", 3600000L, "user@acme.io"); + EdgeMailRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.ACTIVATION); + assertThat(request.getActivationLink()).isEqualTo("http://activate"); + assertThat(request.getTtlMs()).isEqualTo(3600000L); + assertThat(request.getTo()).isEqualTo("user@acme.io"); + } + + @Test + void sendTbEmail_delegatesToCloud() throws Exception { + TbEmail tbEmail = TbEmail.builder().from("noreply@acme.io").to("user@acme.io") + .subject("Alarm").body("Alarm").html(true).build(); + mailService.send(tenantId, null, tbEmail); + EdgeMailRequest request = captureRequest(tenantId); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.SEND_TB_EMAIL); + assertThat(request.getTbEmail()).isNotNull(); + assertThat(request.getTbEmail().getTo()).isEqualTo("user@acme.io"); + assertThat(request.getTbEmail().getSubject()).isEqualTo("Alarm"); + } + + @Test + void twoFa_delegatesToCloud() throws Exception { + mailService.sendTwoFaVerificationEmail("user@acme.io", "123456", 120); + EdgeMailRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.TWO_FA); + assertThat(request.getVerificationCode()).isEqualTo("123456"); + assertThat(request.getExpirationTimeSeconds()).isEqualTo(120); + } + + @Test + void testMail_delegatesToCloud() throws Exception { + JsonNode config = JacksonUtil.newObjectNode().put("mailFrom", "noreply@acme.io"); + mailService.sendTestMail(config, "user@acme.io"); + EdgeMailRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.TEST_MAIL); + assertThat(request.getTestConfig()).isEqualTo(config); + assertThat(request.getTo()).isEqualTo("user@acme.io"); + } + + @Test + void isConfigured_alwaysTrueOnEdge() { + assertThat(mailService.isConfigured(tenantId)).isTrue(); + } + + private EdgeMailRequest captureRequest(TenantId expectedTenantId) throws Exception { + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(cloudEventService).saveCloudEvent(eq(expectedTenantId), eq(CloudEventType.TENANT), + eq(EdgeEventActionType.SEND_EMAIL), eq(expectedTenantId), bodyCaptor.capture()); + return JacksonUtil.convertValue(bodyCaptor.getValue(), EdgeMailRequest.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java index 75a3423b984..7130ea6a2f9 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java @@ -15,150 +15,69 @@ */ package org.thingsboard.server.service.sms; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.TestPropertySource; -import org.apache.commons.lang3.RandomStringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; -import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration; -import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; -import org.thingsboard.server.controller.AbstractControllerTest; -import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.common.data.sms.config.TestSmsRequest; +import org.thingsboard.server.dao.cloud.CloudEventService; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; +import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; -@DaoSqlTest -@TestPropertySource(properties = { - "usage.stats.report.enabled=true", - "usage.stats.report.interval=1", - "usage.stats.report.urgent_interval=1" -}) -public class DefaultSmsServiceTest extends AbstractControllerTest { - @MockitoSpyBean - private DefaultSmsService defaultSmsService; - @Autowired - private AdminSettingsService adminSettingsService; +public class DefaultSmsServiceTest { - private TenantProfile tenantProfile; + private final TenantId tenantId = new TenantId(UUID.randomUUID()); - @Before - public void before() throws Exception { - loginSysAdmin(); - prepareSmsSystemSetting(); - } + private CloudEventService cloudEventService; + private DefaultSmsService smsService; - @After - public void after() throws Exception { - saveTenantProfileWitConfiguration(tenantProfile, new DefaultTenantProfileConfiguration()); - adminSettingsService.deleteAdminSettingsByTenantIdAndKey(TenantId.SYS_TENANT_ID, "sms"); - resetTokens(); + @BeforeEach + void setUp() { + cloudEventService = mock(CloudEventService.class); + smsService = new DefaultSmsService(cloudEventService); } @Test - public void testLimitSmsMessagingByTenantProfileSettings() throws Exception { - tenantProfile = getDefaultTenantProfile(); - - DefaultTenantProfileConfiguration config = createTenantProfileConfigurationWithSmsLimits(10, true); - saveTenantProfileWitConfiguration(tenantProfile, config); - - for (int i = 0; i < 10; i++) { - doReturn(1).when(defaultSmsService).sendSms(any(), any()); - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.secure().nextNumeric(10)}, "Message"); - } - - //wait 1 sec so that api usage state is updated - TimeUnit.SECONDS.sleep(1); - assertThrows(RuntimeException.class, () -> { - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.secure().nextNumeric(10)}, "Message"); - }, "SMS sending is disabled due to API limits!"); + public void sendSms_delegatesToCloud() throws Exception { + smsService.sendSms(tenantId, null, new String[]{"+15551234567", "+15559876543"}, "Edge alert"); + EdgeSmsRequest request = captureRequest(tenantId); + assertThat(request.getMethod()).isEqualTo(EdgeSmsRequest.SmsMethod.SEND_SMS); + assertThat(request.getNumbers()).containsExactly("+15551234567", "+15559876543"); + assertThat(request.getMessage()).isEqualTo("Edge alert"); } @Test - public void testLimitSmsMessagingIfSmsDisabled() throws Exception { - tenantProfile = getDefaultTenantProfile(); - - DefaultTenantProfileConfiguration config = createTenantProfileConfigurationWithSmsLimits(0, false); - saveTenantProfileWitConfiguration(tenantProfile, config); - - TimeUnit.SECONDS.sleep(1); - assertThrows(RuntimeException.class, () -> { - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.secure().nextNumeric(10)}, "Message"); - }, "SMS sending is disabled due to API limits!"); - - //enable sms messaging - DefaultTenantProfileConfiguration config2 = createTenantProfileConfigurationWithSmsLimits(0, true); - saveTenantProfileWitConfiguration(tenantProfile, config2); - TimeUnit.SECONDS.sleep(1); - - for (int i = 0; i < 10; i++) { - doReturn(1).when(defaultSmsService).sendSms(any(), any()); - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.secure().nextNumeric(10)}, "Message"); - } + public void sendTestSms_delegatesToCloud() throws Exception { + TestSmsRequest testSmsRequest = new TestSmsRequest(); + testSmsRequest.setNumberTo("+15551234567"); + testSmsRequest.setMessage("Test"); + smsService.sendTestSms(testSmsRequest); + EdgeSmsRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeSmsRequest.SmsMethod.SEND_TEST_SMS); + assertThat(request.getTestSmsRequest()).isNotNull(); + assertThat(request.getTestSmsRequest().getNumberTo()).isEqualTo("+15551234567"); + assertThat(request.getTestSmsRequest().getMessage()).isEqualTo("Test"); } - private TenantProfile getDefaultTenantProfile() throws Exception { - - PageLink pageLink = new PageLink(17); - PageData pageData = doGetTypedWithPageLink("/api/tenantProfiles?", - new TypeReference<>(){}, pageLink); - Assert.assertFalse(pageData.hasNext()); - Assert.assertEquals(1, pageData.getTotalElements()); - List tenantProfiles = new ArrayList<>(pageData.getData()); - - Optional optionalDefaultProfile = tenantProfiles.stream().filter(TenantProfile::isDefault).reduce((a, b) -> null); - Assert.assertTrue(optionalDefaultProfile.isPresent()); - - return optionalDefaultProfile.get(); - } - - private DefaultTenantProfileConfiguration createTenantProfileConfigurationWithSmsLimits(Integer maxSms, Boolean smsEnabled) { - DefaultTenantProfileConfiguration.DefaultTenantProfileConfigurationBuilder builder = DefaultTenantProfileConfiguration.builder(); - builder.maxSms(maxSms); - builder.smsEnabled(smsEnabled); - return builder.build(); - + @Test + public void isConfigured_alwaysTrueOnEdge() { + assertThat(smsService.isConfigured(tenantId)).isTrue(); } - private void saveTenantProfileWitConfiguration(TenantProfile tenantProfile, TenantProfileConfiguration tenantProfileConfiguration) { - TenantProfileData tenantProfileData = tenantProfile.getProfileData(); - tenantProfileData.setConfiguration(tenantProfileConfiguration); - TenantProfile savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); - Assert.assertNotNull(savedTenantProfile); + private EdgeSmsRequest captureRequest(TenantId expectedTenantId) throws Exception { + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(cloudEventService).saveCloudEvent(eq(expectedTenantId), eq(CloudEventType.TENANT), + eq(EdgeEventActionType.SEND_SMS), eq(expectedTenantId), bodyCaptor.capture()); + return JacksonUtil.convertValue(bodyCaptor.getValue(), EdgeSmsRequest.class); } - private void prepareSmsSystemSetting() throws Exception { - if (doGet("/api/admin/settings/sms").andReturn().getResponse().getStatus() == 404) { - AdminSettings adminSettings = new AdminSettings(); - ObjectNode value = JacksonUtil.newObjectNode(); - value.put("numberFrom", "+12543223870"); - value.put("accountSid", "testAcc"); - value.put("accountToken", "testToken"); - value.put("type", "TWILIO"); - adminSettings.setKey("sms"); - adminSettings.setJsonValue(value); - - doPost("/api/admin/settings", adminSettings).andExpect(status().isOk()); - } - } -} \ No newline at end of file +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java index f3b60159064..f2c7fcb9af0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java @@ -52,7 +52,10 @@ public enum EdgeEventActionType { WIDGET_BUNDLE_TYPES_REQUEST(null), // deprecated ENTITY_VIEW_REQUEST(null), // deprecated ENTITY_MERGE_REQUEST(null), // deprecated - DEVICE_PROFILE_DEVICES_REQUEST(null); // deprecated + DEVICE_PROFILE_DEVICES_REQUEST(null), // deprecated + SEND_EMAIL(null), + SEND_SMS(null), + SEND_NOTIFICATION(null); private final ActionType actionType; diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index b68597842d8..f02af241d52 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -367,6 +367,24 @@ message CalculatedFieldRequestMsg { string entityType = 3; } +message SendEmailUplinkMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string request = 3; +} + +message SendSmsUplinkMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string request = 3; +} + +message SendNotificationUplinkMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string request = 3; +} + // DEPRECATED. FOR REMOVAL message UserCredentialsRequestMsg { option deprecated = true; @@ -471,6 +489,9 @@ message UplinkMsg { repeated UserUpdateMsg userUpdateMsg = 28; repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 29; repeated ApiKeyUpdateMsg apiKeyUpdateMsg = 30; + repeated SendEmailUplinkMsg sendEmailUplinkMsg = 35; + repeated SendSmsUplinkMsg sendSmsUplinkMsg = 36; + repeated SendNotificationUplinkMsg sendNotificationUplinkMsg = 37; } message UplinkResponseMsg { From a15b9065a7fb9091f9777d4e780f9f2c82f25232 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Fri, 17 Jul 2026 14:39:07 +0300 Subject: [PATCH 2/2] Delegate mail/SMS/notification sends from edge to cloud --- .../cloud/BaseCloudManagerService.java | 3 + .../service/cloud/CloudContextComponent.java | 12 + .../rpc/processor/MailCloudProcessor.java | 53 +++ .../SendNotificationCloudProcessor.java | 53 +++ .../rpc/processor/SmsCloudProcessor.java | 53 +++ .../update/DefaultDataUpdateService.java | 46 +++ .../service/mail/DefaultMailService.java | 349 +++++------------- .../server/service/mail/EdgeMailRequest.java | 80 ++++ .../mail/RefreshTokenExpCheckService.java | 66 +--- .../notification/EdgeNotificationRequest.java | 76 ++++ .../MobileAppNotificationChannel.java | 63 ++-- .../channels/SlackNotificationChannel.java | 33 +- .../server/service/sms/DefaultSmsService.java | 104 ++---- .../server/service/sms/EdgeSmsRequest.java | 51 +++ .../service/mail/DefaultMailServiceTest.java | 113 ++++++ .../service/sms/DefaultSmsServiceTest.java | 172 +++------ .../common/data/edge/EdgeEventActionType.java | 5 +- common/edge-api/src/main/proto/edge.proto | 21 ++ 18 files changed, 775 insertions(+), 578 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/BaseCloudManagerService.java b/application/src/main/java/org/thingsboard/server/service/cloud/BaseCloudManagerService.java index 2364844b414..a48d8e496ca 100644 --- a/application/src/main/java/org/thingsboard/server/service/cloud/BaseCloudManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/cloud/BaseCloudManagerService.java @@ -679,6 +679,9 @@ private UplinkMsg convertEventToUplink(CloudEvent cloudEvent) { case RELATION_REQUEST -> cloudCtx.getRelationProcessor().convertRelationRequestEventToUplink(cloudEvent); case CALCULATED_FIELD_REQUEST -> cloudCtx.getCalculatedFieldProcessor().convertCalculatedFieldRequestEventToUplink(cloudEvent); case RPC_CALL -> cloudCtx.getDeviceProcessor().convertRpcCallEventToUplink(cloudEvent); + case SEND_EMAIL -> cloudCtx.getMailProcessor().convertSendEmailEventToUplink(cloudEvent); + case SEND_SMS -> cloudCtx.getSmsProcessor().convertSendSmsEventToUplink(cloudEvent); + case SEND_NOTIFICATION -> cloudCtx.getSendNotificationProcessor().convertSendNotificationEventToUplink(cloudEvent); default -> { log.warn("Unsupported action type [{}]", cloudEvent); yield null; diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java b/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java index 345315094b7..90ddabeb359 100644 --- a/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/cloud/CloudContextComponent.java @@ -38,8 +38,11 @@ import org.thingsboard.server.service.cloud.rpc.processor.DeviceProfileCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.EdgeCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.EntityViewCloudProcessor; +import org.thingsboard.server.service.cloud.rpc.processor.MailCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.NotificationCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.OAuth2CloudProcessor; +import org.thingsboard.server.service.cloud.rpc.processor.SendNotificationCloudProcessor; +import org.thingsboard.server.service.cloud.rpc.processor.SmsCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.OtaPackageCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.QueueCloudProcessor; import org.thingsboard.server.service.cloud.rpc.processor.RelationCloudProcessor; @@ -167,6 +170,15 @@ public CloudContextComponent(List processors) { @Autowired private CalculatedFieldCloudProcessor calculatedFieldProcessor; + @Autowired + private MailCloudProcessor mailProcessor; + + @Autowired + private SmsCloudProcessor smsProcessor; + + @Autowired + private SendNotificationCloudProcessor sendNotificationProcessor; + // callback @Autowired private DbCallbackExecutorService dbCallbackExecutorService; diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java new file mode 100644 index 00000000000..c18b6d8be9a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/MailCloudProcessor.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 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.cloud.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.cloud.CloudEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.SendEmailUplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +/** + * Converts a SEND_EMAIL cloud event into a {@link SendEmailUplinkMsg}. The Edge does not render or + * transmit the mail itself; it forwards the serialized {@code EdgeMailRequest} (carried in the cloud + * event body) to the Cloud, which resolves the config, renders and sends via its own SMTP. + */ +@Slf4j +@Component +@TbCoreComponent +public class MailCloudProcessor { + + public UplinkMsg convertSendEmailEventToUplink(CloudEvent cloudEvent) { + log.trace("Executing convertSendEmailEventToUplink, cloudEvent [{}]", cloudEvent); + TenantId tenantId = cloudEvent.getTenantId(); + SendEmailUplinkMsg sendEmailUplinkMsg = SendEmailUplinkMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequest(JacksonUtil.toString(cloudEvent.getEntityBody())) + .build(); + + return UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addSendEmailUplinkMsg(sendEmailUplinkMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java new file mode 100644 index 00000000000..7f475f40ac6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SendNotificationCloudProcessor.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 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.cloud.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.cloud.CloudEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.SendNotificationUplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +/** + * Converts a SEND_NOTIFICATION cloud event into a {@link SendNotificationUplinkMsg}. The Edge forwards the + * serialized {@code EdgeNotificationRequest} (carried in the cloud event body) to the Cloud, which resolves + * the channel credentials and delivers (Slack post / FCM push). + */ +@Slf4j +@Component +@TbCoreComponent +public class SendNotificationCloudProcessor { + + public UplinkMsg convertSendNotificationEventToUplink(CloudEvent cloudEvent) { + log.trace("Executing convertSendNotificationEventToUplink, cloudEvent [{}]", cloudEvent); + TenantId tenantId = cloudEvent.getTenantId(); + SendNotificationUplinkMsg sendNotificationUplinkMsg = SendNotificationUplinkMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequest(JacksonUtil.toString(cloudEvent.getEntityBody())) + .build(); + + return UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addSendNotificationUplinkMsg(sendNotificationUplinkMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java new file mode 100644 index 00000000000..c38d2399e46 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cloud/rpc/processor/SmsCloudProcessor.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 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.cloud.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.cloud.CloudEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.SendSmsUplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +/** + * Converts a SEND_SMS cloud event into a {@link SendSmsUplinkMsg}. The Edge does not resolve or transmit + * the SMS itself; it forwards the serialized {@code EdgeSmsRequest} (carried in the cloud event body) to + * the Cloud, which resolves the config and sends via its own provider. + */ +@Slf4j +@Component +@TbCoreComponent +public class SmsCloudProcessor { + + public UplinkMsg convertSendSmsEventToUplink(CloudEvent cloudEvent) { + log.trace("Executing convertSendSmsEventToUplink, cloudEvent [{}]", cloudEvent); + TenantId tenantId = cloudEvent.getTenantId(); + SendSmsUplinkMsg sendSmsUplinkMsg = SendSmsUplinkMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequest(JacksonUtil.toString(cloudEvent.getEntityBody())) + .build(); + + return UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addSendSmsUplinkMsg(sendSmsUplinkMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java index 8a985fcbb75..efcd5fb5c93 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java @@ -26,6 +26,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.AlarmSeverity; @@ -46,6 +47,7 @@ import org.thingsboard.server.dao.cloud.EdgeSettingsService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.widget.WidgetsBundleService; @@ -53,11 +55,13 @@ import org.thingsboard.server.service.component.RuleNodeClassInfo; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DbUpgradeExecutorService; +import org.thingsboard.server.service.install.SystemDataLoaderService; import org.thingsboard.server.service.install.lts.LtsMigrationService; import org.thingsboard.server.utils.TbNodeUpgradeUtils; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.concurrent.ExecutionException; @Service @@ -85,13 +89,55 @@ public class DefaultDataUpdateService implements DataUpdateService { @Autowired private WidgetsBundleService widgetsBundleService; + @Autowired + private AdminSettingsService adminSettingsService; + + @Autowired + private SystemDataLoaderService systemDataLoaderService; + @Override public void updateData() throws Exception { log.info("Updating data ..."); ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion()); + purgeAdminSettings(); log.info("Data updated."); } + private void purgeAdminSettings() throws Exception { + log.info("Purging admin settings"); + Set keep = Set.of("general", "connectivity"); + List scopes = new ArrayList<>(); + scopes.add(TenantId.SYS_TENANT_ID); + new PageDataIterable<>(tenantService::findTenantsIds, DEFAULT_PAGE_SIZE).forEach(scopes::add); + boolean systemJwtRemoved = false; + for (TenantId scope : scopes) { + List keysToDelete = new ArrayList<>(); + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData page; + do { + page = adminSettingsService.findAllByTenantId(scope, pageLink); + for (AdminSettings adminSettings : page.getData()) { + if (!keep.contains(adminSettings.getKey())) { + keysToDelete.add(adminSettings.getKey()); + } + } + pageLink = pageLink.nextPageLink(); + } while (page.hasNext()); + for (String key : keysToDelete) { + adminSettingsService.deleteAdminSettingsByTenantIdAndKey(scope, key); + if (TenantId.SYS_TENANT_ID.equals(scope) && "jwt".equals(key)) { + systemJwtRemoved = true; + } + } + if (!keysToDelete.isEmpty()) { + log.info("Purged {} admin settings for tenant [{}]: {}", keysToDelete.size(), scope, keysToDelete); + } + } + if (systemJwtRemoved) { + systemDataLoaderService.createRandomJwtSettings(); + } + } + @Override public void upgradeRuleNodes() { int totalRuleNodesUpgraded = 0; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index e19435eea6a..aad7656c8a8 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -17,90 +17,65 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; -import freemarker.template.Configuration; -import freemarker.template.Template; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.MessageSource; -import org.springframework.context.annotation.Lazy; import org.springframework.core.NestedRuntimeException; import org.springframework.core.io.InputStreamSource; import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; -import org.springframework.ui.freemarker.FreeMarkerTemplateUtils; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.TbEmail; import org.thingsboard.server.cache.limits.RateLimitService; -import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiFeature; -import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageRecordState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.exception.RateLimitExceededException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; -import org.thingsboard.server.common.stats.TbApiUsageReportClient; -import org.thingsboard.server.dao.exception.IncorrectParameterException; -import org.thingsboard.server.dao.settings.AdminSettingsService; -import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.dao.cloud.CloudEventService; import java.io.ByteArrayInputStream; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +/** + * On the Edge the MailService is a thin client. Any send that would rely on admin-configured (tenant or + * system) mail settings cannot be resolved on the Edge, because the mail settings are no longer synced to + * the Edge. Instead of resolving config, rendering templates and opening SMTP locally, the Edge packages + * the call into an {@link EdgeMailRequest} and enqueues a SEND_EMAIL cloud event; the Cloud resolves the + * config, renders and transmits via its own SMTP. The only local send that remains is the rule-node + * "own SMTP" path, where the caller supplies a fully-configured {@link JavaMailSender} with plaintext + * credentials (no admin settings involved). + */ @Slf4j @Service @RequiredArgsConstructor public class DefaultMailService implements MailService { - private static final String TARGET_EMAIL = "targetEmail"; - private static final String UTF_8 = "UTF-8"; - private static final long DEFAULT_TIMEOUT = 10_000; - private final ScheduledExecutorService timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); - private final MessageSource messages; - private final Configuration freemarkerConfig; - private final AdminSettingsService adminSettingsService; - private final TbApiUsageReportClient apiUsageClient; - @Lazy - private final TbApiUsageStateService apiUsageStateService; private final MailSenderInternalExecutorService mailExecutorService; private final PasswordResetExecutorService passwordResetExecutorService; - private final TbMailContextComponent ctx; private final RateLimitService rateLimitService; + private final CloudEventService cloudEventService; @Value("${mail.per_tenant_rate_limits:}") private String perTenantRateLimitConfig; - private TbMailSender mailSender; - - private String mailFrom; - - private long timeout; - - @PostConstruct - private void init() { - // edge-only: merge comment - // updateMailConfiguration(); - } - @PreDestroy public void destroy() { timeoutScheduler.shutdownNow(); @@ -108,78 +83,42 @@ public void destroy() { @Override public void updateMailConfiguration() { - AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); - if (settings != null) { - JsonNode jsonConfig = settings.getJsonValue(); - mailSender = new TbMailSender(ctx, jsonConfig); - mailFrom = jsonConfig.get("mailFrom").asText(); - timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); - } else { - throw new IncorrectParameterException("Failed to update mail configuration. Settings not found!"); - } + // Mail is sent from the Cloud on the Edge; there is no local mail configuration to update. } @Override public void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException { - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(tenantId, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.SEND_BASIC) + .to(email).subject(subject).message(message).build()); } @Override public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException { - TbMailSender testMailSender = new TbMailSender(ctx, 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); - - Map model = new HashMap<>(); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("test.ftl", model); - - sendMail(testMailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.TEST_MAIL) + .testConfig(jsonConfig).to(email).build()); } @Override public void sendActivationEmail(String activationLink, long ttlMs, String email) throws ThingsboardException { - String subject = messages.getMessage("activation.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("activationLink", activationLink); - model.put("activationLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("activation.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.ACTIVATION) + .activationLink(activationLink).ttlMs(ttlMs).to(email).build()); } @Override public void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException { - - String subject = messages.getMessage("account.activated.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("loginLink", loginLink); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("account.activated.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.ACCOUNT_ACTIVATED) + .loginLink(loginLink).to(email).build()); } @Override public void sendResetPasswordEmail(String passwordResetLink, long ttlMs, String email) throws ThingsboardException { - - String subject = messages.getMessage("reset.password.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("passwordResetLink", passwordResetLink); - model.put("passwordResetLinkTtlInHours", (int) Math.ceil(ttlMs / 3600000.0)); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("reset.password.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.RESET_PASSWORD) + .passwordResetLink(passwordResetLink).ttlMs(ttlMs).to(email).build()); } @Override @@ -195,194 +134,93 @@ public void sendResetPasswordEmailAsync(String passwordResetLink, long ttlMs, St @Override public void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException { - - String subject = messages.getMessage("password.was.reset.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("loginLink", loginLink); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("password.was.reset.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.PASSWORD_WAS_RESET) + .loginLink(loginLink).to(email).build()); } @Override - public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException { - sendMail(tenantId, customerId, tbEmail, this.mailSender, timeout); + public void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException { + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.ACCOUNT_LOCKOUT) + .lockoutEmail(lockoutEmail).to(email).maxFailedLoginAttempts(maxFailedLoginAttempts).build()); } @Override - public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { - sendMail(tenantId, customerId, tbEmail, javaMailSender, timeout); - } - - private void sendMail(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { - if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) { - if (tenantId != null && !tenantId.isSysTenantId() && StringUtils.isNotEmpty(perTenantRateLimitConfig) && - !rateLimitService.checkRateLimit(LimitedApi.EMAILS, (Object) tenantId, perTenantRateLimitConfig)) { - throw new RateLimitExceededException(LimitedApi.EMAILS); - } - try { - MimeMessage mailMsg = javaMailSender.createMimeMessage(); - boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty()); - MimeMessageHelper helper = new MimeMessageHelper(mailMsg, multipart, "UTF-8"); - helper.setFrom(StringUtils.isBlank(tbEmail.getFrom()) ? mailFrom : tbEmail.getFrom()); - helper.setTo(tbEmail.getTo().split("\\s*,\\s*")); - if (!StringUtils.isBlank(tbEmail.getCc())) { - helper.setCc(tbEmail.getCc().split("\\s*,\\s*")); - } - if (!StringUtils.isBlank(tbEmail.getBcc())) { - helper.setBcc(tbEmail.getBcc().split("\\s*,\\s*")); - } - helper.setSubject(tbEmail.getSubject()); - helper.setText(tbEmail.getBody(), tbEmail.isHtml()); - - if (multipart) { - for (String imgId : tbEmail.getImages().keySet()) { - String imgValue = tbEmail.getImages().get(imgId); - String value = imgValue.replaceFirst("^data:image/[^;]*;base64,?", ""); - byte[] bytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(value); - String contentType = helper.getFileTypeMap().getContentType(imgId); - InputStreamSource iss = () -> new ByteArrayInputStream(bytes); - helper.addInline(imgId, iss, contentType); - } - } - sendMailWithTimeout(javaMailSender, helper.getMimeMessage(), timeout); - apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.EMAIL_EXEC_COUNT, 1); - } catch (Exception e) { - throw handleException(e); - } - } else { - throw new RuntimeException("Email sending is disabled due to API limits!"); - } + public void sendTwoFaVerificationEmail(String email, String verificationCode, int expirationTimeSeconds) throws ThingsboardException { + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.TWO_FA) + .to(email).verificationCode(verificationCode).expirationTimeSeconds(expirationTimeSeconds).build()); } @Override - public void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException { - String subject = messages.getMessage("account.lockout.subject", null, Locale.US); - - Map model = new HashMap<>(); - model.put("lockoutAccount", lockoutEmail); - model.put("maxFailedLoginAttempts", maxFailedLoginAttempts); - model.put(TARGET_EMAIL, email); - - String message = mergeTemplateIntoString("account.lockout.ftl", model); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageRecordState recordState) throws ThingsboardException { + enqueue(TenantId.SYS_TENANT_ID, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.API_USAGE_STATE) + .apiFeature(apiFeature).stateValue(stateValue).to(email).recordState(recordState).build()); } @Override - public void sendTwoFaVerificationEmail(String email, String verificationCode, int expirationTimeSeconds) throws ThingsboardException { - String subject = messages.getMessage("2fa.verification.code.subject", null, Locale.US); - String message = mergeTemplateIntoString("2fa.verification.code.ftl", Map.of( - TARGET_EMAIL, email, - "code", verificationCode, - "expirationTimeSeconds", expirationTimeSeconds - )); - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException { + enqueue(tenantId, EdgeMailRequest.builder() + .method(EdgeMailRequest.MailMethod.SEND_TB_EMAIL) + .tbEmail(tbEmail).build()); } @Override - public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageRecordState recordState) throws ThingsboardException { - String subject = messages.getMessage("api.usage.state", null, Locale.US); - - Map model = new HashMap<>(); - model.put("apiFeature", apiFeature.getLabel()); - model.put(TARGET_EMAIL, email); - - String message = switch (stateValue) { - case ENABLED -> { - model.put("apiLabel", toEnabledValueLabel(apiFeature)); - yield mergeTemplateIntoString("state.enabled.ftl", model); + public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { + // Rule-node "own SMTP" path: the caller supplies a fully-configured sender with plaintext + // credentials, so this is sent locally on the Edge without any admin config resolution. + if (tenantId != null && !tenantId.isSysTenantId() && StringUtils.isNotEmpty(perTenantRateLimitConfig) && + !rateLimitService.checkRateLimit(LimitedApi.EMAILS, (Object) tenantId, perTenantRateLimitConfig)) { + throw new RateLimitExceededException(LimitedApi.EMAILS); + } + try { + MimeMessage mailMsg = javaMailSender.createMimeMessage(); + boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty()); + MimeMessageHelper helper = new MimeMessageHelper(mailMsg, multipart, "UTF-8"); + helper.setFrom(tbEmail.getFrom()); + helper.setTo(tbEmail.getTo().split("\\s*,\\s*")); + if (!StringUtils.isBlank(tbEmail.getCc())) { + helper.setCc(tbEmail.getCc().split("\\s*,\\s*")); } - case WARNING -> { - model.put("apiValueLabel", toDisabledValueLabel(apiFeature) + " " + toWarningValueLabel(recordState)); - yield mergeTemplateIntoString("state.warning.ftl", model); + if (!StringUtils.isBlank(tbEmail.getBcc())) { + helper.setBcc(tbEmail.getBcc().split("\\s*,\\s*")); } - case DISABLED -> { - model.put("apiLimitValueLabel", toDisabledValueLabel(apiFeature) + " " + toDisabledValueLabel(recordState)); - yield mergeTemplateIntoString("state.disabled.ftl", model); + helper.setSubject(tbEmail.getSubject()); + helper.setText(tbEmail.getBody(), tbEmail.isHtml()); + + if (multipart) { + for (String imgId : tbEmail.getImages().keySet()) { + String imgValue = tbEmail.getImages().get(imgId); + String value = imgValue.replaceFirst("^data:image/[^;]*;base64,?", ""); + byte[] bytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(value); + String contentType = helper.getFileTypeMap().getContentType(imgId); + InputStreamSource iss = () -> new ByteArrayInputStream(bytes); + helper.addInline(imgId, iss, contentType); + } } - }; - - sendMail(mailSender, mailFrom, email, subject, message, timeout); + sendMailWithTimeout(javaMailSender, helper.getMimeMessage(), timeout); + } catch (Exception e) { + throw handleException(e); + } } @Override public void testConnection(TenantId tenantId) throws Exception { - mailSender.testConnection(); + // Mail is sent from the Cloud on the Edge; there is no local SMTP connection to test. } @Override public boolean isConfigured(TenantId tenantId) { - return mailSender != null; - } - - private String toEnabledValueLabel(ApiFeature apiFeature) { - return switch (apiFeature) { - case DB -> "save"; - case TRANSPORT -> "receive"; - case JS -> "invoke"; - case RE -> "process"; - case EMAIL, SMS -> "send"; - case ALARM -> "create"; - default -> throw new RuntimeException("Not implemented!"); - }; - } - - private String toDisabledValueLabel(ApiFeature apiFeature) { - return switch (apiFeature) { - case DB -> "saved"; - case TRANSPORT -> "received"; - case JS -> "invoked"; - case RE -> "processed"; - case EMAIL, SMS -> "sent"; - case ALARM -> "created"; - default -> throw new RuntimeException("Not implemented!"); - }; - } - - private String toWarningValueLabel(ApiUsageRecordState recordState) { - String valueInM = recordState.getValueAsString(); - String thresholdInM = recordState.getThresholdAsString(); - return switch (recordState.getKey()) { - case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> valueInM + " out of " + thresholdInM + " allowed data points"; - case TRANSPORT_MSG_COUNT -> valueInM + " out of " + thresholdInM + " allowed messages"; - case JS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; - case TBEL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Tbel functions"; - case RE_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; - case EMAIL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Email messages"; - case SMS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed SMS messages"; - default -> throw new RuntimeException("Not implemented!"); - }; - } - - private String toDisabledValueLabel(ApiUsageRecordState recordState) { - return switch (recordState.getKey()) { - case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> recordState.getValueAsString() + " data points"; - case TRANSPORT_MSG_COUNT -> recordState.getValueAsString() + " messages"; - case JS_EXEC_COUNT -> "JavaScript functions " + recordState.getValueAsString() + " times"; - case TBEL_EXEC_COUNT -> "TBEL functions " + recordState.getValueAsString() + " times"; - case RE_EXEC_COUNT -> recordState.getValueAsString() + " Rule Engine messages"; - case EMAIL_EXEC_COUNT -> recordState.getValueAsString() + " Email messages"; - case SMS_EXEC_COUNT -> recordState.getValueAsString() + " SMS messages"; - default -> throw new RuntimeException("Not implemented!"); - }; + // Mail sending is delegated to the Cloud, which owns the configuration. + return true; } - private void sendMail(JavaMailSenderImpl mailSender, String mailFrom, String email, - String subject, String message, long timeout) throws ThingsboardException { + private void enqueue(TenantId tenantId, EdgeMailRequest request) throws ThingsboardException { try { - MimeMessage mimeMsg = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(mimeMsg, UTF_8); - helper.setFrom(mailFrom); - helper.setTo(email); - helper.setSubject(subject); - helper.setText(message, true); - - sendMailWithTimeout(mailSender, helper.getMimeMessage(), timeout); + cloudEventService.saveCloudEvent(tenantId, CloudEventType.TENANT, EdgeEventActionType.SEND_EMAIL, + tenantId, JacksonUtil.valueToTree(request)); } catch (Exception e) { throw handleException(e); } @@ -401,17 +239,6 @@ private void sendMailWithTimeout(JavaMailSender mailSender, MimeMessage msg, lon } } - private String mergeTemplateIntoString(String templateLocation, - Map model) throws ThingsboardException { - try { - Template template = freemarkerConfig.getTemplate(templateLocation); - return FreeMarkerTemplateUtils.processTemplateIntoString(template, model); - } catch (Exception e) { - log.warn("Failed to process mail template: {}", ExceptionUtils.getRootCauseMessage(e)); - throw new ThingsboardException("Failed to process mail template: " + e.getMessage(), e, ThingsboardErrorCode.GENERAL); - } - } - protected ThingsboardException handleException(Throwable exception) { if (exception instanceof ThingsboardException thingsboardException) { return thingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java b/application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java new file mode 100644 index 00000000000..733f824b0ef --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/EdgeMailRequest.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2026 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.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.rule.engine.api.TbEmail; +import org.thingsboard.server.common.data.ApiFeature; +import org.thingsboard.server.common.data.ApiUsageRecordState; +import org.thingsboard.server.common.data.ApiUsageStateValue; + +/** + * Describes a mail send that the Edge delegates to the Cloud. On the Edge, a mail send that depends + * on admin-configured (tenant/system) mail settings is packaged into this request and enqueued as a + * SEND_EMAIL cloud event. On the Cloud, {@code method} selects the matching {@code MailService} call + * so the Cloud resolves the config, renders the template and transmits via its own SMTP. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EdgeMailRequest { + + public enum MailMethod { + SEND_BASIC, // sendEmail(to, subject, message) + SEND_TB_EMAIL, // send(TbEmail) + ACTIVATION, // sendActivationEmail(activationLink, ttlMs, to) + ACCOUNT_ACTIVATED, // sendAccountActivatedEmail(loginLink, to) + RESET_PASSWORD, // sendResetPasswordEmail(passwordResetLink, ttlMs, to) + PASSWORD_WAS_RESET,// sendPasswordWasResetEmail(loginLink, to) + TWO_FA, // sendTwoFaVerificationEmail(to, verificationCode, expirationTimeSeconds) + ACCOUNT_LOCKOUT, // sendAccountLockoutEmail(lockoutEmail, to, maxFailedLoginAttempts) + API_USAGE_STATE, // sendApiFeatureStateEmail(apiFeature, stateValue, to, recordState) + TEST_MAIL // sendTestMail(config, to) + } + + private MailMethod method; + + private String to; + private String subject; + private String message; + + private TbEmail tbEmail; + + private String activationLink; + private String loginLink; + private String passwordResetLink; + private Long ttlMs; + + private String verificationCode; + private Integer expirationTimeSeconds; + + private String lockoutEmail; + private Integer maxFailedLoginAttempts; + + private ApiFeature apiFeature; + private ApiUsageStateValue stateValue; + private ApiUsageRecordState recordState; + + private JsonNode testConfig; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java b/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java index 03cb60a8c23..d23024184d4 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/RefreshTokenExpCheckService.java @@ -15,76 +15,22 @@ */ 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 java.util.concurrent.TimeUnit; - -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}", - timeUnit = TimeUnit.SECONDS) - 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("refreshToken") - && jsonValue.has("refreshTokenExpires")) { - try { - long expiresIn = jsonValue.get("refreshTokenExpires").longValue(); - long tokenLifeDuration = expiresIn - System.currentTimeMillis(); - if (tokenLifeDuration < 0) { - ((ObjectNode) jsonValue).put("tokenGenerated", false); - ((ObjectNode) jsonValue).remove("refreshToken"); - ((ObjectNode) jsonValue).remove("refreshTokenExpires"); - - adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, settings); - } else if (tokenLifeDuration < 604800000L) { //less than 7 days - log.info("Trying to refresh refresh token."); + public static final int AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS = 90; - String clientId = jsonValue.get("clientId").asText(); - String clientSecret = jsonValue.get("clientSecret").asText(); - String refreshToken = jsonValue.get("refreshToken").asText(); - String tokenUri = jsonValue.get("tokenUri").asText(); + // Disabled on the Edge. The Edge is a thin mail client: the Cloud sends all mail and owns + // refreshing the mail OAuth2 (Office 365) refresh token. Mail settings are no longer synced to the Edge, + // and refreshing the token here too would race the Cloud and could invalidate the single-use refresh + // token. Token refresh runs on the Cloud only. - 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); - } - } catch (Exception e) { - log.error("Error occurred while checking token", e); - } - } - } - } -} \ No newline at end of file +} diff --git a/application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java b/application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java new file mode 100644 index 00000000000..b135c574e84 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/EdgeNotificationRequest.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2026 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.notification; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Describes a notification delivery that the Edge delegates to the Cloud, for channels whose credentials + * live only on the Cloud (they are stored in the {@code notifications} admin settings that no longer sync + * to the Edge). {@code method} selects the channel: + *
    + *
  • {@code SEND_SLACK} - the Cloud resolves the Slack bot token and posts {@code message} (plus any + * {@code files}) to {@code conversationId}. The whole send is delegated.
  • + *
  • {@code SEND_MOBILE_PUSH} - the Edge keeps the notification record, device-token lookup and unread + * count locally and delegates only the FCM push: the Cloud resolves the Firebase credentials and pushes + * the already-built payload ({@code subject}/{@code body}/{@code data}/{@code badge}) to every + * {@code fcmToken}.
  • + *
+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EdgeNotificationRequest { + + public enum NotificationMethod { + SEND_SLACK, + SEND_MOBILE_PUSH + } + + private NotificationMethod method; + + // SEND_SLACK + private String conversationId; + private String message; + private List files; + + // SEND_MOBILE_PUSH + private Set fcmTokens; + private String subject; + private String body; + private Map data; + private Integer badge; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SlackFileData { + private String name; + private String type; + private byte[] data; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java index 1c61999a9d9..3cf3e49eaf8 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java @@ -18,45 +18,47 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Strings; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.MessagingErrorCode; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.api.notification.FirebaseService; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationStatus; import org.thingsboard.server.common.data.notification.info.NotificationInfo; -import org.thingsboard.server.common.data.notification.settings.MobileAppNotificationDeliveryMethodConfig; -import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.cloud.CloudEventService; import org.thingsboard.server.dao.notification.NotificationService; -import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.notification.EdgeNotificationRequest; import org.thingsboard.server.service.notification.NotificationProcessingContext; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Optional; -import java.util.Set; import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.MOBILE_APP; +/** + * On the Edge the mobile-app channel keeps everything that is Edge-local - the notification record, the + * user's device-token lookup and the unread count - and delegates only the FCM push to the Cloud, because + * the Firebase credentials live only on the Cloud (in the {@code notifications} admin settings that no + * longer sync to the Edge). The already-built payload and the device tokens are enqueued as a + * SEND_NOTIFICATION cloud event; the Cloud resolves the credentials and pushes via FCM. Invalid-token + * pruning is not performed on the Edge for delegated pushes (the fire-and-forget uplink has no return path). + */ @Component @RequiredArgsConstructor -@Slf4j public class MobileAppNotificationChannel implements NotificationChannel { - private final FirebaseService firebaseService; private final UserService userService; private final NotificationService notificationService; - private final NotificationSettingsService notificationSettingsService; + private final CloudEventService cloudEventService; @Override public void sendNotification(User recipient, MobileAppDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception { @@ -92,31 +94,17 @@ public void sendNotification(User recipient, MobileAppDeliveryMethodNotification throw new IllegalArgumentException("User doesn't use the mobile app"); } - MobileAppNotificationDeliveryMethodConfig config = ctx.getDeliveryMethodConfig(MOBILE_APP); - String credentials = config.getFirebaseServiceAccountCredentials(); - Set validTokens = new HashSet<>(mobileSessions.keySet()); - - String subject = processedTemplate.getSubject(); - String body = processedTemplate.getBody(); - Map data = getNotificationData(processedTemplate, ctx); int unreadCount = notificationService.countUnreadNotificationsByRecipientId(ctx.getTenantId(), MOBILE_APP, recipient.getId()); - for (String token : mobileSessions.keySet()) { - try { - firebaseService.sendMessage(ctx.getTenantId(), credentials, token, subject, body, data, unreadCount); - } catch (FirebaseMessagingException e) { - MessagingErrorCode errorCode = e.getMessagingErrorCode(); - if (errorCode == MessagingErrorCode.UNREGISTERED || errorCode == MessagingErrorCode.INVALID_ARGUMENT || errorCode == MessagingErrorCode.SENDER_ID_MISMATCH) { - validTokens.remove(token); - userService.removeMobileSession(recipient.getTenantId(), token); - log.debug("[{}][{}] Removed invalid FCM token due to {} {} ({})", recipient.getTenantId(), recipient.getId(), errorCode, e.getMessage(), token); - continue; - } - throw new RuntimeException("Failed to send message via FCM: " + e.getMessage(), e); - } - } - if (validTokens.isEmpty()) { - throw new IllegalArgumentException("User doesn't use the mobile app"); - } + EdgeNotificationRequest edgeRequest = EdgeNotificationRequest.builder() + .method(EdgeNotificationRequest.NotificationMethod.SEND_MOBILE_PUSH) + .fcmTokens(new HashSet<>(mobileSessions.keySet())) + .subject(processedTemplate.getSubject()) + .body(processedTemplate.getBody()) + .data(getNotificationData(processedTemplate, ctx)) + .badge(unreadCount) + .build(); + cloudEventService.saveCloudEvent(ctx.getTenantId(), CloudEventType.TENANT, EdgeEventActionType.SEND_NOTIFICATION, + ctx.getTenantId(), JacksonUtil.valueToTree(edgeRequest)); } private Map getNotificationData(MobileAppDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) { @@ -146,10 +134,7 @@ private Map getNotificationData(MobileAppDeliveryMethodNotificat @Override public void check(TenantId tenantId) throws Exception { - NotificationSettings systemSettings = notificationSettingsService.findNotificationSettings(TenantId.SYS_TENANT_ID); - if (!systemSettings.getDeliveryMethodsConfigs().containsKey(MOBILE_APP)) { - throw new RuntimeException("Push-notifications to mobile are not configured"); - } + // Firebase credentials live on the Cloud; the Edge delegates the push, so nothing to verify locally. } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java index 2ac7a812622..55babe0cd45 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java @@ -17,35 +17,44 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.thingsboard.rule.engine.api.notification.SlackService; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; -import org.thingsboard.server.common.data.notification.settings.NotificationSettings; -import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; import org.thingsboard.server.common.data.notification.template.SlackDeliveryMethodNotificationTemplate; -import org.thingsboard.server.dao.notification.NotificationSettingsService; +import org.thingsboard.server.dao.cloud.CloudEventService; +import org.thingsboard.server.service.notification.EdgeNotificationRequest; import org.thingsboard.server.service.notification.NotificationProcessingContext; +/** + * On the Edge the Slack channel is a thin client. The Slack bot token lives only on the Cloud (in the + * {@code notifications} admin settings that no longer sync to the Edge), so the Edge packages the send into + * an {@link EdgeNotificationRequest} and enqueues a SEND_NOTIFICATION cloud event; the Cloud resolves the + * token and posts to Slack. + */ @Component @RequiredArgsConstructor public class SlackNotificationChannel implements NotificationChannel { - private final SlackService slackService; - private final NotificationSettingsService notificationSettingsService; + private final CloudEventService cloudEventService; @Override public void sendNotification(SlackConversation conversation, SlackDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws Exception { - SlackNotificationDeliveryMethodConfig config = ctx.getDeliveryMethodConfig(NotificationDeliveryMethod.SLACK); - slackService.sendMessage(ctx.getTenantId(), config.getBotToken(), conversation.getId(), processedTemplate.getBody()); + EdgeNotificationRequest request = EdgeNotificationRequest.builder() + .method(EdgeNotificationRequest.NotificationMethod.SEND_SLACK) + .conversationId(conversation.getId()) + .message(processedTemplate.getBody()) + .build(); + + cloudEventService.saveCloudEvent(ctx.getTenantId(), CloudEventType.TENANT, EdgeEventActionType.SEND_NOTIFICATION, + ctx.getTenantId(), JacksonUtil.valueToTree(request)); } @Override public void check(TenantId tenantId) throws Exception { - NotificationSettings notificationSettings = notificationSettingsService.findNotificationSettings(tenantId); - if (!notificationSettings.getDeliveryMethodsConfigs().containsKey(NotificationDeliveryMethod.SLACK)) { - throw new RuntimeException("Slack API token is not configured"); - } + // Slack config lives on the Cloud; the Edge delegates the send, so nothing to verify locally. } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java index 233d9092c07..245ddad7420 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java @@ -15,118 +15,65 @@ */ package org.thingsboard.server.service.sms; -import com.fasterxml.jackson.databind.JsonNode; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedRuntimeException; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.SmsService; -import org.thingsboard.rule.engine.api.sms.SmsSender; -import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; -import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.sms.config.SmsProviderConfiguration; import org.thingsboard.server.common.data.sms.config.TestSmsRequest; -import org.thingsboard.server.common.stats.TbApiUsageReportClient; -import org.thingsboard.server.dao.settings.AdminSettingsService; -import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.dao.cloud.CloudEventService; +/** + * On the Edge the SmsService is a thin client. Any send that would rely on admin-configured (tenant or + * system) SMS provider settings cannot be resolved on the Edge, because the SMS settings are no longer + * synced to the Edge. Instead of resolving config and opening a provider connection locally, the Edge + * packages the call into an {@link EdgeSmsRequest} and enqueues a SEND_SMS cloud event; the Cloud resolves + * the config and transmits via its own provider. The rule-node "own plaintext config" path builds its own + * {@code SmsSender} directly and never goes through this service. + */ @Slf4j @Service @RequiredArgsConstructor public class DefaultSmsService implements SmsService { - private final SmsSenderFactory smsSenderFactory; - private final AdminSettingsService adminSettingsService; - private final TbApiUsageStateService apiUsageStateService; - private final TbApiUsageReportClient apiUsageClient; - - private SmsSender smsSender; - - @PostConstruct - private void init() { - updateSmsConfiguration(); - } - - @PreDestroy - private void destroy() { - if (this.smsSender != null) { - this.smsSender.destroy(); - } - } + private final CloudEventService cloudEventService; @Override public void updateSmsConfiguration() { - AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "sms"); - if (settings != null) { - try { - JsonNode jsonConfig = settings.getJsonValue(); - SmsProviderConfiguration configuration = JacksonUtil.convertValue(jsonConfig, SmsProviderConfiguration.class); - SmsSender newSmsSender = this.smsSenderFactory.createSmsSender(configuration); - if (this.smsSender != null) { - this.smsSender.destroy(); - } - this.smsSender = newSmsSender; - } catch (Exception e) { - log.error("Failed to create SMS sender", e); - } - } - } - - protected int sendSms(String numberTo, String message) throws ThingsboardException { - if (this.smsSender == null) { - throw new ThingsboardException("Unable to send SMS: no SMS provider configured!", ThingsboardErrorCode.GENERAL); - } - return this.sendSms(this.smsSender, numberTo, message); + // SMS is sent from the Cloud on the Edge; there is no local SMS configuration to update. } @Override public void sendSms(TenantId tenantId, CustomerId customerId, String[] numbersTo, String message) throws ThingsboardException { - if (apiUsageStateService.getApiUsageState(tenantId).isSmsSendEnabled()) { - int smsCount = 0; - try { - for (String numberTo : numbersTo) { - smsCount += this.sendSms(numberTo, message); - } - } finally { - if (smsCount > 0) { - apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.SMS_EXEC_COUNT, smsCount); - } - } - } else { - throw new RuntimeException("SMS sending is disabled due to API limits!"); - } + enqueue(tenantId, EdgeSmsRequest.builder() + .method(EdgeSmsRequest.SmsMethod.SEND_SMS) + .numbers(numbersTo).message(message).build()); } @Override public void sendTestSms(TestSmsRequest testSmsRequest) throws ThingsboardException { - SmsSender testSmsSender; - try { - testSmsSender = this.smsSenderFactory.createSmsSender(testSmsRequest.getProviderConfiguration()); - } catch (Exception e) { - throw handleException(e); - } - this.sendSms(testSmsSender, testSmsRequest.getNumberTo(), testSmsRequest.getMessage()); - testSmsSender.destroy(); + enqueue(TenantId.SYS_TENANT_ID, EdgeSmsRequest.builder() + .method(EdgeSmsRequest.SmsMethod.SEND_TEST_SMS) + .testSmsRequest(testSmsRequest).build()); } @Override public boolean isConfigured(TenantId tenantId) { - return smsSender != null; + // SMS sending is delegated to the Cloud, which owns the configuration. + return true; } - private int sendSms(SmsSender smsSender, String numberTo, String message) throws ThingsboardException { + private void enqueue(TenantId tenantId, EdgeSmsRequest request) throws ThingsboardException { try { - int sentSms = smsSender.sendSms(numberTo, message); - log.trace("Successfully sent sms to number: {}", numberTo); - return sentSms; + cloudEventService.saveCloudEvent(tenantId, CloudEventType.TENANT, EdgeEventActionType.SEND_SMS, + tenantId, JacksonUtil.valueToTree(request)); } catch (Exception e) { throw handleException(e); } @@ -140,8 +87,7 @@ private ThingsboardException handleException(Exception exception) { message = exception.getMessage(); } log.warn("Unable to send SMS: {}", message); - return new ThingsboardException(String.format("Unable to send SMS: %s", message), - ThingsboardErrorCode.GENERAL); + return new ThingsboardException(String.format("Unable to send SMS: %s", message), ThingsboardErrorCode.GENERAL); } } diff --git a/application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java b/application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java new file mode 100644 index 00000000000..649b8a5a856 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/EdgeSmsRequest.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 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.sms; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.sms.config.TestSmsRequest; + +/** + * Describes an SMS send that the Edge delegates to the Cloud. On the Edge, a send that depends on + * admin-configured (tenant/system) SMS provider settings cannot be resolved locally, so the call is + * packaged into this request and enqueued as a SEND_SMS cloud event; on the Cloud, {@code method} + * selects the matching {@code SmsService} call so the Cloud resolves the config and transmits via its + * own provider. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EdgeSmsRequest { + + public enum SmsMethod { + SEND_SMS, // sendSms(customerId, numbersTo, message) + SEND_TEST_SMS // sendTestSms(testSmsRequest) + } + + private SmsMethod method; + + private String[] numbers; + private String message; + + private TestSmsRequest testSmsRequest; + +} diff --git a/application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java b/application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java new file mode 100644 index 00000000000..e830b48e021 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/mail/DefaultMailServiceTest.java @@ -0,0 +1,113 @@ +/** + * Copyright © 2016-2026 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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.TbEmail; +import org.thingsboard.server.cache.limits.RateLimitService; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.cloud.CloudEventService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class DefaultMailServiceTest { + + private final TenantId tenantId = new TenantId(UUID.randomUUID()); + + private CloudEventService cloudEventService; + private DefaultMailService mailService; + + @BeforeEach + void setUp() { + cloudEventService = mock(CloudEventService.class); + mailService = new DefaultMailService( + mock(MailSenderInternalExecutorService.class), + mock(PasswordResetExecutorService.class), + mock(RateLimitService.class), + cloudEventService); + } + + @AfterEach + void tearDown() { + mailService.destroy(); + } + + @Test + void sendActivationEmail_delegatesToCloud() throws Exception { + mailService.sendActivationEmail("http://activate", 3600000L, "user@acme.io"); + EdgeMailRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.ACTIVATION); + assertThat(request.getActivationLink()).isEqualTo("http://activate"); + assertThat(request.getTtlMs()).isEqualTo(3600000L); + assertThat(request.getTo()).isEqualTo("user@acme.io"); + } + + @Test + void sendTbEmail_delegatesToCloud() throws Exception { + TbEmail tbEmail = TbEmail.builder().from("noreply@acme.io").to("user@acme.io") + .subject("Alarm").body("Alarm").html(true).build(); + mailService.send(tenantId, null, tbEmail); + EdgeMailRequest request = captureRequest(tenantId); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.SEND_TB_EMAIL); + assertThat(request.getTbEmail()).isNotNull(); + assertThat(request.getTbEmail().getTo()).isEqualTo("user@acme.io"); + assertThat(request.getTbEmail().getSubject()).isEqualTo("Alarm"); + } + + @Test + void twoFa_delegatesToCloud() throws Exception { + mailService.sendTwoFaVerificationEmail("user@acme.io", "123456", 120); + EdgeMailRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.TWO_FA); + assertThat(request.getVerificationCode()).isEqualTo("123456"); + assertThat(request.getExpirationTimeSeconds()).isEqualTo(120); + } + + @Test + void testMail_delegatesToCloud() throws Exception { + JsonNode config = JacksonUtil.newObjectNode().put("mailFrom", "noreply@acme.io"); + mailService.sendTestMail(config, "user@acme.io"); + EdgeMailRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeMailRequest.MailMethod.TEST_MAIL); + assertThat(request.getTestConfig()).isEqualTo(config); + assertThat(request.getTo()).isEqualTo("user@acme.io"); + } + + @Test + void isConfigured_alwaysTrueOnEdge() { + assertThat(mailService.isConfigured(tenantId)).isTrue(); + } + + private EdgeMailRequest captureRequest(TenantId expectedTenantId) throws Exception { + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(cloudEventService).saveCloudEvent(eq(expectedTenantId), eq(CloudEventType.TENANT), + eq(EdgeEventActionType.SEND_EMAIL), eq(expectedTenantId), bodyCaptor.capture()); + return JacksonUtil.convertValue(bodyCaptor.getValue(), EdgeMailRequest.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java index 8d23b32946a..7130ea6a2f9 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java @@ -15,149 +15,69 @@ */ package org.thingsboard.server.service.sms; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.cloud.CloudEventType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; -import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration; -import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; -import org.thingsboard.server.controller.AbstractControllerTest; -import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.common.data.sms.config.TestSmsRequest; +import org.thingsboard.server.dao.cloud.CloudEventService; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; +import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; -@DaoSqlTest -@TestPropertySource(properties = { - "usage.stats.report.enabled=true", - "usage.stats.report.interval=1", -}) -public class DefaultSmsServiceTest extends AbstractControllerTest { - @MockitoSpyBean - private DefaultSmsService defaultSmsService; - @Autowired - private AdminSettingsService adminSettingsService; +public class DefaultSmsServiceTest { - private TenantProfile tenantProfile; + private final TenantId tenantId = new TenantId(UUID.randomUUID()); - @Before - public void before() throws Exception { - loginSysAdmin(); - prepareSmsSystemSetting(); - } + private CloudEventService cloudEventService; + private DefaultSmsService smsService; - @After - public void after() throws Exception { - saveTenantProfileWitConfiguration(tenantProfile, new DefaultTenantProfileConfiguration()); - adminSettingsService.deleteAdminSettingsByTenantIdAndKey(TenantId.SYS_TENANT_ID, "sms"); - resetTokens(); + @BeforeEach + void setUp() { + cloudEventService = mock(CloudEventService.class); + smsService = new DefaultSmsService(cloudEventService); } @Test - public void testLimitSmsMessagingByTenantProfileSettings() throws Exception { - tenantProfile = getDefaultTenantProfile(); - - DefaultTenantProfileConfiguration config = createTenantProfileConfigurationWithSmsLimits(10, true); - saveTenantProfileWitConfiguration(tenantProfile, config); - - for (int i = 0; i < 10; i++) { - doReturn(1).when(defaultSmsService).sendSms(any(), any()); - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); - } - - //wait 1 sec so that api usage state is updated - TimeUnit.SECONDS.sleep(1); - assertThrows(RuntimeException.class, () -> { - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); - }, "SMS sending is disabled due to API limits!"); + public void sendSms_delegatesToCloud() throws Exception { + smsService.sendSms(tenantId, null, new String[]{"+15551234567", "+15559876543"}, "Edge alert"); + EdgeSmsRequest request = captureRequest(tenantId); + assertThat(request.getMethod()).isEqualTo(EdgeSmsRequest.SmsMethod.SEND_SMS); + assertThat(request.getNumbers()).containsExactly("+15551234567", "+15559876543"); + assertThat(request.getMessage()).isEqualTo("Edge alert"); } @Test - public void testLimitSmsMessagingIfSmsDisabled() throws Exception { - tenantProfile = getDefaultTenantProfile(); - - DefaultTenantProfileConfiguration config = createTenantProfileConfigurationWithSmsLimits(0, false); - saveTenantProfileWitConfiguration(tenantProfile, config); - - TimeUnit.SECONDS.sleep(1); - assertThrows(RuntimeException.class, () -> { - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); - }, "SMS sending is disabled due to API limits!"); - - //enable sms messaging - DefaultTenantProfileConfiguration config2 = createTenantProfileConfigurationWithSmsLimits(0, true); - saveTenantProfileWitConfiguration(tenantProfile, config2); - TimeUnit.SECONDS.sleep(1); - - for (int i = 0; i < 10; i++) { - doReturn(1).when(defaultSmsService).sendSms(any(), any()); - defaultSmsService.sendSms(tenantId, null, new String[]{RandomStringUtils.randomNumeric(10)}, "Message"); - } + public void sendTestSms_delegatesToCloud() throws Exception { + TestSmsRequest testSmsRequest = new TestSmsRequest(); + testSmsRequest.setNumberTo("+15551234567"); + testSmsRequest.setMessage("Test"); + smsService.sendTestSms(testSmsRequest); + EdgeSmsRequest request = captureRequest(TenantId.SYS_TENANT_ID); + assertThat(request.getMethod()).isEqualTo(EdgeSmsRequest.SmsMethod.SEND_TEST_SMS); + assertThat(request.getTestSmsRequest()).isNotNull(); + assertThat(request.getTestSmsRequest().getNumberTo()).isEqualTo("+15551234567"); + assertThat(request.getTestSmsRequest().getMessage()).isEqualTo("Test"); } - private TenantProfile getDefaultTenantProfile() throws Exception { - - PageLink pageLink = new PageLink(17); - PageData pageData = doGetTypedWithPageLink("/api/tenantProfiles?", - new TypeReference<>(){}, pageLink); - Assert.assertFalse(pageData.hasNext()); - Assert.assertEquals(1, pageData.getTotalElements()); - List tenantProfiles = new ArrayList<>(pageData.getData()); - - Optional optionalDefaultProfile = tenantProfiles.stream().filter(TenantProfile::isDefault).reduce((a, b) -> null); - Assert.assertTrue(optionalDefaultProfile.isPresent()); - - return optionalDefaultProfile.get(); - } - - private DefaultTenantProfileConfiguration createTenantProfileConfigurationWithSmsLimits(Integer maxSms, Boolean smsEnabled) { - DefaultTenantProfileConfiguration.DefaultTenantProfileConfigurationBuilder builder = DefaultTenantProfileConfiguration.builder(); - builder.maxSms(maxSms); - builder.smsEnabled(smsEnabled); - return builder.build(); - + @Test + public void isConfigured_alwaysTrueOnEdge() { + assertThat(smsService.isConfigured(tenantId)).isTrue(); } - private void saveTenantProfileWitConfiguration(TenantProfile tenantProfile, TenantProfileConfiguration tenantProfileConfiguration) { - TenantProfileData tenantProfileData = tenantProfile.getProfileData(); - tenantProfileData.setConfiguration(tenantProfileConfiguration); - TenantProfile savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); - Assert.assertNotNull(savedTenantProfile); + private EdgeSmsRequest captureRequest(TenantId expectedTenantId) throws Exception { + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JsonNode.class); + verify(cloudEventService).saveCloudEvent(eq(expectedTenantId), eq(CloudEventType.TENANT), + eq(EdgeEventActionType.SEND_SMS), eq(expectedTenantId), bodyCaptor.capture()); + return JacksonUtil.convertValue(bodyCaptor.getValue(), EdgeSmsRequest.class); } - private void prepareSmsSystemSetting() throws Exception { - if (doGet("/api/admin/settings/sms").andReturn().getResponse().getStatus() == 404) { - AdminSettings adminSettings = new AdminSettings(); - ObjectNode value = JacksonUtil.newObjectNode(); - value.put("numberFrom", "+12543223870"); - value.put("accountSid", "testAcc"); - value.put("accountToken", "testToken"); - value.put("type", "TWILIO"); - adminSettings.setKey("sms"); - adminSettings.setJsonValue(value); - - doPost("/api/admin/settings", adminSettings).andExpect(status().isOk()); - } - } -} \ No newline at end of file +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java index f3b60159064..f2c7fcb9af0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java @@ -52,7 +52,10 @@ public enum EdgeEventActionType { WIDGET_BUNDLE_TYPES_REQUEST(null), // deprecated ENTITY_VIEW_REQUEST(null), // deprecated ENTITY_MERGE_REQUEST(null), // deprecated - DEVICE_PROFILE_DEVICES_REQUEST(null); // deprecated + DEVICE_PROFILE_DEVICES_REQUEST(null), // deprecated + SEND_EMAIL(null), + SEND_SMS(null), + SEND_NOTIFICATION(null); private final ActionType actionType; diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 376a4d8c5bb..50a02da5d5b 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -347,6 +347,24 @@ message CalculatedFieldRequestMsg { string entityType = 3; } +message SendEmailUplinkMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string request = 3; +} + +message SendSmsUplinkMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string request = 3; +} + +message SendNotificationUplinkMsg { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + string request = 3; +} + // DEPRECATED. FOR REMOVAL message UserCredentialsRequestMsg { option deprecated = true; @@ -447,6 +465,9 @@ message UplinkMsg { repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25; repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26; + repeated SendEmailUplinkMsg sendEmailUplinkMsg = 35; + repeated SendSmsUplinkMsg sendSmsUplinkMsg = 36; + repeated SendNotificationUplinkMsg sendNotificationUplinkMsg = 37; } message UplinkResponseMsg {