feat(sprint7): Phase 4 — Integration (SMTP, tier enforcement, WebSocket)

Phase 4 implementation:
- 4.1 IONOS SMTP email configuration (production + docker profiles)
- 4.2 Portal navigation update (info board, events, forum links)
- 4.3 Tier enforcement: PlanTierService (forum=Pro+, info board limits)
- 4.4 WebSocket real-time updates (WebSocketEventPublisher)
- 4.5 EmailService: notification, event reminder, info board templates + rate limiting
- 4.6 Enterprise custom FROM: CustomMailDomain entity, DNS verification, controller

New files:
- PlanTierService: tier checks for forum/info board/enterprise features
- NotificationDispatchService: EMAIL channel dispatch via preferences
- WebSocketEventPublisher: STOMP topic push for forum/info board/events
- CustomMailDomainService: DNS TXT record verification for custom FROM
- MailSettingsController: Enterprise custom domain API endpoints
- CustomMailDomain entity + repository
- V16 migration: email dispatch index
- V17 migration: custom_mail_domains table
- Frontend: use-forum-subscription + use-info-board-subscription hooks
- Portal navbar: added info board, events, forum navigation items
- i18n: added portal nav translations (de + en)

Also fixed pre-existing Phase 2.5/3 compilation issues:
- Member entity: added userId field
- AuditService: added convenience overloads (logEvent, 4-param log)
- AuditEventType: added INFO_BOARD_POST_UPDATED, INFO_BOARD_POST_DELETED
- QuotaViolationCode: added TIER_UPGRADE_REQUIRED
- StaffPermissionChecker: added requirePermission(UserDetails, ...)
- TenantContext: added getCurrentTenantId() alias
- MemberRepository: added findByUserId, findByClubId, findAllByClubId
- EmailServiceTest: updated for new constructor signature
This commit is contained in:
Patrick Plate
2026-06-13 20:51:10 +02:00
parent a539ed9eb2
commit aabde17532
26 changed files with 1174 additions and 82 deletions
@@ -0,0 +1,103 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.CustomMailDomain;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.CustomMailDomainService;
import de.cannamanage.service.PlanTierService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* REST controller for Enterprise custom email domain management.
* All endpoints require ADMIN role + Enterprise tier.
*/
@RestController
@RequestMapping("/api/v1/settings/mail")
public class MailSettingsController {
private final CustomMailDomainService customMailDomainService;
private final PlanTierService planTierService;
public MailSettingsController(CustomMailDomainService customMailDomainService,
PlanTierService planTierService) {
this.customMailDomainService = customMailDomainService;
this.planTierService = planTierService;
}
/**
* Set a custom FROM address for the club's outbound emails.
* Enterprise tier only.
*/
@PostMapping("/custom-domain")
public ResponseEntity<MailDomainStatusResponse> setCustomDomain(
@Valid @RequestBody CustomMailDomainRequest request) {
UUID tenantId = TenantContext.getCurrentTenantId();
CustomMailDomain domain = customMailDomainService.setCustomDomain(tenantId, request.fromAddress());
return ResponseEntity.ok(toResponse(domain));
}
/**
* Get current custom domain status.
*/
@GetMapping("/custom-domain")
public ResponseEntity<MailDomainStatusResponse> getCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
planTierService.requireEnterpriseTier(tenantId);
return customMailDomainService.getCustomDomain(tenantId)
.map(domain -> ResponseEntity.ok(toResponse(domain)))
.orElse(ResponseEntity.noContent().build());
}
/**
* Trigger DNS verification for the custom domain.
*/
@PostMapping("/custom-domain/verify")
public ResponseEntity<MailDomainStatusResponse> verifyCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
CustomMailDomain domain = customMailDomainService.verifyDomain(tenantId);
return ResponseEntity.ok(toResponse(domain));
}
/**
* Remove custom domain configuration (revert to platform default).
*/
@DeleteMapping("/custom-domain")
public ResponseEntity<Void> removeCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
planTierService.requireEnterpriseTier(tenantId);
customMailDomainService.removeCustomDomain(tenantId);
return ResponseEntity.noContent().build();
}
private MailDomainStatusResponse toResponse(CustomMailDomain domain) {
return new MailDomainStatusResponse(
domain.getFromAddress(),
domain.getDomain(),
domain.isVerified(),
domain.getVerificationToken(),
domain.getVerifiedAt() != null ? domain.getVerifiedAt().toString() : null,
"cannamanage-verify=" + domain.getVerificationToken()
);
}
// --- DTOs ---
public record CustomMailDomainRequest(
@NotBlank @Email String fromAddress
) {}
public record MailDomainStatusResponse(
String fromAddress,
String domain,
boolean verified,
String verificationToken,
String verifiedAt,
String requiredDnsTxtRecord
) {}
}
@@ -54,4 +54,35 @@ public class StaffPermissionChecker {
.map(staff -> staff.hasPermission(required))
.orElse(false);
}
/**
* Imperative permission check — throws AccessDeniedException if permission is missing.
* Used by controllers that need to guard specific endpoints programmatically.
*/
public void requirePermission(org.springframework.security.core.userdetails.UserDetails principal, StaffPermission required) {
if (principal == null) {
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
}
// Convert UserDetails to Authentication-like check
UUID userId = UUID.fromString(principal.getUsername());
boolean isAdmin = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) return;
boolean isStaff = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_STAFF"));
if (!isStaff) {
throw new org.springframework.security.access.AccessDeniedException("Insufficient permissions");
}
boolean hasPermission = staffAccountRepository.findByUserId(userId)
.filter(StaffAccount::isActive)
.map(staff -> staff.hasPermission(required))
.orElse(false);
if (!hasPermission) {
throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required);
}
}
}
@@ -17,9 +17,18 @@ management.endpoint.health.show-details=never
# drag /actuator/health to DOWN (503), which would mark the container unhealthy.
management.health.mail.enabled=false
# Disable mail in Docker (no SMTP container)
spring.mail.host=localhost
spring.mail.port=1025
# IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production
spring.mail.host=${SMTP_HOST:smtp.ionos.de}
spring.mail.port=${SMTP_PORT:587}
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
spring.mail.password=${IONOS_SMTP_PASSWORD:}
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:true}
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:true}
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
cannamanage.mail.rate-limit=${MAIL_RATE_LIMIT:50}
# Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
@@ -53,3 +53,25 @@ springdoc.swagger-ui.enabled=false
# App base URL
app.base-url=https://cannamanage.plate-software.de
# IONOS SMTP relay (plate-software.de)
spring.mail.host=smtp.ionos.de
spring.mail.port=587
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
spring.mail.password=${IONOS_SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
cannamanage.mail.rate-limit=50
# Web Push VAPID keys
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
push.vapid.subject=mailto:admin@cannamanage.plate-software.de
# Firebase Cloud Messaging
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
@@ -0,0 +1,6 @@
-- V16: Index for faster email dispatch queries on notification_preferences
-- Used by NotificationDispatchService to find users with EMAIL channel enabled per tenant
CREATE INDEX IF NOT EXISTS idx_notification_preferences_email_enabled
ON notification_preferences(tenant_id, channel, enabled)
WHERE channel = 'EMAIL' AND enabled = true;
@@ -0,0 +1,15 @@
-- V17: Custom mail domains for Enterprise tier clubs
-- Allows Enterprise clubs to use a verified custom FROM address for outbound emails
CREATE TABLE custom_mail_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL UNIQUE,
from_address VARCHAR(255) NOT NULL,
domain VARCHAR(255) NOT NULL,
verification_token VARCHAR(64) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT false,
verified_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_custom_mail_domains_tenant ON custom_mail_domains(tenant_id);