feat(tenant): welcome email, admin email display, async delete fix
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m29s

- Send branded welcome email to tenant admin after provisioning completes
  (includes username and dashboard URL)
- Store admin_email on tenant entity (V004 migration)
- Show admin email in vendor tenant list table and detail page
- Fix ClickHouse cleanup: skip materialized views (can't ALTER DELETE on MVs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-28 19:53:47 +02:00
parent bd301ad1fe
commit 345bc4a92b
12 changed files with 186 additions and 5 deletions

View File

@@ -0,0 +1,109 @@
package io.cameleer.saas.notification;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.provisioning.ProvisioningProperties;
import io.cameleer.saas.vendor.EmailConnectorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Properties;
@Service
public class TenantWelcomeNotificationService {
private static final Logger log = LoggerFactory.getLogger(TenantWelcomeNotificationService.class);
private final EmailConnectorService emailConnectorService;
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
public TenantWelcomeNotificationService(EmailConnectorService emailConnectorService,
LogtoManagementClient logtoClient,
ProvisioningProperties provisioningProps) {
this.emailConnectorService = emailConnectorService;
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
}
/**
* Sends a welcome email to the tenant admin after provisioning completes.
* Fire-and-forget: logs a warning on failure but does not throw.
*/
public void sendWelcomeEmail(String toEmail, String username, String tenantName, String slug) {
try {
doSend(toEmail, username, tenantName, slug);
} catch (Exception e) {
log.warn("Failed to send welcome email to {}: {}", toEmail, e.getMessage());
}
}
@SuppressWarnings("unchecked")
private void doSend(String toEmail, String username, String tenantName, String slug) throws Exception {
var connectorStatus = emailConnectorService.getEmailConnector();
if (connectorStatus == null) {
log.debug("No email connector configured — skipping welcome email for {}", toEmail);
return;
}
var connectors = logtoClient.listConnectors();
var raw = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElse(null);
if (raw == null) return;
var config = (Map<String, Object>) raw.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
String host = connectorStatus.host();
int port = connectorStatus.port();
String smtpUsername = connectorStatus.username();
String fromEmail = connectorStatus.fromEmail();
String password = String.valueOf(auth.getOrDefault("pass", ""));
String dashboardUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/t/" + slug;
String watermarkUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
String htmlBody = new ClassPathResource("email-templates/welcome-tenant.html")
.getContentAsString(StandardCharsets.UTF_8)
.replace("{{watermarkUrl}}", watermarkUrl)
.replace("{{tenantName}}", tenantName)
.replace("{{username}}", username)
.replace("{{dashboardUrl}}", dashboardUrl);
var sender = new JavaMailSenderImpl();
sender.setHost(host);
sender.setPort(port);
sender.setUsername(smtpUsername);
sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
Properties props = sender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
if (port == 465) {
props.put("mail.smtp.ssl.enable", "true");
} else {
props.put("mail.smtp.starttls.enable", "true");
}
props.put("mail.smtp.timeout", "10000");
props.put("mail.smtp.connectiontimeout", "10000");
var mimeMessage = sender.createMimeMessage();
var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
helper.setTo(toEmail);
helper.setFrom(fromEmail);
helper.setSubject("Your Cameleer tenant is ready");
helper.setText(htmlBody, true);
sender.send(mimeMessage);
log.info("Welcome email sent to {} for tenant {}", toEmail, slug);
}
}

View File

@@ -44,11 +44,13 @@ public class TenantDataCleanupService {
try (Connection conn = DriverManager.getConnection(url, props.clickhouseUser(), props.clickhousePassword());
Statement stmt = conn.createStatement()) {
// Find all tables with a tenant_id column
// Find all base tables with a tenant_id column (skip materialized views)
List<String> tables = new ArrayList<>();
try (ResultSet rs = stmt.executeQuery(
"SELECT DISTINCT table FROM system.columns " +
"WHERE database = currentDatabase() AND name = 'tenant_id'")) {
"SELECT DISTINCT c.table FROM system.columns c " +
"INNER JOIN system.tables t ON c.table = t.name AND c.database = t.database " +
"WHERE c.database = currentDatabase() AND c.name = 'tenant_id' " +
"AND t.engine NOT LIKE '%MaterializedView%'")) {
while (rs.next()) {
tables.add(rs.getString(1));
}

View File

@@ -61,6 +61,9 @@ public class TenantEntity {
@Column(name = "db_password")
private String dbPassword;
@Column(name = "admin_email")
private String adminEmail;
@Column(name = "ca_applied_at")
private Instant caAppliedAt;
@@ -105,6 +108,8 @@ public class TenantEntity {
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
public String getDbPassword() { return dbPassword; }
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
public String getAdminEmail() { return adminEmail; }
public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; }
public Instant getCaAppliedAt() { return caAppliedAt; }
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
public Instant getCreatedAt() { return createdAt; }

View File

@@ -11,6 +11,7 @@ public record TenantResponse(
String slug,
String tier,
String status,
String adminEmail,
String serverEndpoint,
String provisionError,
Instant createdAt,
@@ -20,6 +21,7 @@ public record TenantResponse(
return new TenantResponse(
e.getId(), e.getName(), e.getSlug(),
e.getTier().name(), e.getStatus().name(),
e.getAdminEmail(),
e.getServerEndpoint(), e.getProvisionError(),
e.getCreatedAt(), e.getUpdatedAt()
);

View File

@@ -45,6 +45,7 @@ public class VendorTenantController {
String slug,
String tier,
String status,
String adminEmail,
String serverState,
String licenseExpiry,
String provisionError,
@@ -94,6 +95,7 @@ public class VendorTenantController {
return new VendorTenantSummary(
tenant.getId(), tenant.getName(), tenant.getSlug(),
tenant.getTier().name(), tenant.getStatus().name(),
tenant.getAdminEmail(),
status.state().name(), licenseExpiry, tenant.getProvisionError(),
agentCount, environmentCount, agentLimit
);

View File

@@ -56,6 +56,7 @@ public class VendorTenantService {
private final ProvisioningProperties provisioningProps;
private final TenantDataCleanupService dataCleanupService;
private final TenantDatabaseService tenantDatabaseService;
private final io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService;
private final VendorTenantService self;
public VendorTenantService(TenantService tenantService,
@@ -70,6 +71,7 @@ public class VendorTenantService {
ProvisioningProperties provisioningProps,
TenantDataCleanupService dataCleanupService,
TenantDatabaseService tenantDatabaseService,
io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService,
@Lazy VendorTenantService self) {
this.tenantService = tenantService;
this.tenantRepository = tenantRepository;
@@ -83,6 +85,7 @@ public class VendorTenantService {
this.provisioningProps = provisioningProps;
this.dataCleanupService = dataCleanupService;
this.tenantDatabaseService = tenantDatabaseService;
this.welcomeNotificationService = welcomeNotificationService;
this.self = self;
}
@@ -91,6 +94,12 @@ public class VendorTenantService {
// 1. Create tenant record (sets status = PROVISIONING) + Logto org
TenantEntity tenant = tenantService.create(request, actorId);
// Store admin email on entity
if (request.adminEmail() != null && !request.adminEmail().isBlank()) {
tenant.setAdminEmail(request.adminEmail());
tenantRepository.save(tenant);
}
// 2. Create initial admin user in Logto org (if credentials provided)
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
@@ -218,6 +227,13 @@ public class VendorTenantService {
}
log.info("Tenant {} provisioned successfully", slug);
// Send welcome email to tenant admin
if (tenant.getAdminEmail() != null) {
String username = tenant.getAdminEmail().substring(0, tenant.getAdminEmail().indexOf('@'));
welcomeNotificationService.sendWelcomeEmail(
tenant.getAdminEmail(), username, tenant.getName(), slug);
}
} else {
tenant.setProvisionError(result.error());
tenantRepository.save(tenant);

View File

@@ -0,0 +1 @@
ALTER TABLE tenants ADD COLUMN admin_email VARCHAR(255);

View File

@@ -0,0 +1,24 @@
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to Cameleer</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your tenant <strong>{{tenantName}}</strong> has been provisioned and is ready to use.</p>
<div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
<p style="color:#444;font-size:13px;line-height:1.8;margin:0;">
<strong>Username:</strong> {{username}}<br/>
<strong>Dashboard:</strong> <a href="{{dashboardUrl}}" style="color:#C6820E;text-decoration:none;">{{dashboardUrl}}</a>
</p>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">Sign in with the credentials provided by your administrator. We recommend changing your password and enabling MFA after your first login.</p>
<a href="{{dashboardUrl}}" style="display:inline-block;background:#C6820E;color:#ffffff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Open Dashboard</a>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>

View File

@@ -77,6 +77,9 @@ class VendorTenantServiceTest {
@Mock
private TenantDatabaseService tenantDatabaseService;
@Mock
private io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService;
private VendorTenantService vendorTenantService;
@BeforeEach
@@ -91,11 +94,13 @@ class VendorTenantServiceTest {
vendorTenantService = new VendorTenantService(
tenantService, tenantRepository, licenseService, signingKeyService,
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, null);
auditService, provisioningProps, dataCleanupService, tenantDatabaseService,
welcomeNotificationService, null);
vendorTenantService = new VendorTenantService(
tenantService, tenantRepository, licenseService, signingKeyService,
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService);
auditService, provisioningProps, dataCleanupService, tenantDatabaseService,
welcomeNotificationService, vendorTenantService);
// Enable transaction synchronization so afterCommit callbacks can be registered
if (!TransactionSynchronizationManager.isSynchronizationActive()) {

View File

@@ -400,6 +400,12 @@ export function TenantDetailPage() {
<span className={styles.kvLabel}>Slug</span>
<span className={styles.kvValueMono}>{tenant.slug}</span>
</div>
{tenant.adminEmail && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Admin</span>
<span className={styles.kvValue}>{tenant.adminEmail}</span>
</div>
)}
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Created</span>
<span className={styles.kvValue}>{new Date(tenant.createdAt).toLocaleDateString()}</span>

View File

@@ -41,6 +41,13 @@ const columns: Column<VendorTenantSummary>[] = [
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.slug}</span>
),
},
{
key: 'adminEmail',
header: 'Admin',
render: (_v, row) => (
<span style={{ fontSize: '0.875rem' }}>{row.adminEmail ?? '—'}</span>
),
},
{
key: 'tier',
header: 'Tier',

View File

@@ -4,6 +4,7 @@ export interface TenantResponse {
slug: string;
tier: string;
status: string;
adminEmail: string | null;
serverEndpoint: string | null;
provisionError: string | null;
createdAt: string;
@@ -75,6 +76,7 @@ export interface VendorTenantSummary {
slug: string;
tier: string;
status: string;
adminEmail: string | null;
serverState: string;
licenseExpiry: string | null;
provisionError: string | null;