mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-21 01:01:05 -05:00
wired in email templates
This commit is contained in:
@@ -46,8 +46,7 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
// protected
|
// protected
|
||||||
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
||||||
.requestMatchers("/api/v1/admin/**").authenticated()
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
|
|
||||||
// everything else (adjust later as you lock down)
|
// everything else (adjust later as you lock down)
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package group.goforward.battlbuilder.controllers;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.repos.EmailRequestRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/email")
|
||||||
|
public class EmailTrackingController {
|
||||||
|
|
||||||
|
// 1x1 transparent GIF
|
||||||
|
private static final byte[] PIXEL = new byte[] {
|
||||||
|
71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59
|
||||||
|
};
|
||||||
|
|
||||||
|
private final EmailRequestRepository repo;
|
||||||
|
|
||||||
|
public EmailTrackingController(EmailRequestRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/open/{id}", produces = "image/gif")
|
||||||
|
public ResponseEntity<byte[]> open(@PathVariable Long id) {
|
||||||
|
repo.findById(id).ifPresent(r -> {
|
||||||
|
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
|
||||||
|
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
|
||||||
|
repo.save(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
.body(PIXEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/click/{id}")
|
||||||
|
public ResponseEntity<Void> click(@PathVariable Long id, @RequestParam String url) {
|
||||||
|
repo.findById(id).ifPresent(r -> {
|
||||||
|
if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now());
|
||||||
|
r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1);
|
||||||
|
repo.save(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseEntity.status(302).location(URI.create(url)).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
package group.goforward.battlbuilder.model;
|
package group.goforward.battlbuilder.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "email_requests")
|
@Table(name = "email_requests")
|
||||||
@NamedQuery(
|
@NamedQueries({
|
||||||
|
@NamedQuery(
|
||||||
name = "EmailRequest.findSent",
|
name = "EmailRequest.findSent",
|
||||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
|
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
|
||||||
)
|
),
|
||||||
|
@NamedQuery(
|
||||||
@NamedQuery(
|
|
||||||
name = "EmailRequest.findFailed",
|
name = "EmailRequest.findFailed",
|
||||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
|
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
|
||||||
)
|
),
|
||||||
|
@NamedQuery(
|
||||||
@NamedQuery(
|
|
||||||
name = "EmailRequest.findPending",
|
name = "EmailRequest.findPending",
|
||||||
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
|
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
|
||||||
)
|
)
|
||||||
|
})
|
||||||
public class EmailRequest {
|
public class EmailRequest {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@@ -34,101 +35,95 @@ public class EmailRequest {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
@Column(name = "sent_at")
|
@Column(name = "template_key", length = 100)
|
||||||
private LocalDateTime sentAt;
|
private String templateKey;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private EmailStatus status; // PENDING, SENT, FAILED
|
private EmailStatus status; // PENDING, SENT, FAILED
|
||||||
|
|
||||||
|
@Column(name = "sent_at")
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
|
||||||
@Column(name = "error_message")
|
@Column(name = "error_message")
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
|
|
||||||
|
@Column(name = "opened_at")
|
||||||
|
private LocalDateTime openedAt;
|
||||||
|
|
||||||
|
@Column(name = "open_count", nullable = false)
|
||||||
|
private Integer openCount = 0;
|
||||||
|
|
||||||
|
@Column(name = "clicked_at")
|
||||||
|
private LocalDateTime clickedAt;
|
||||||
|
|
||||||
|
@Column(name = "click_count", nullable = false)
|
||||||
|
private Integer clickCount = 0;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@PrePersist
|
// ✅ should be updatable
|
||||||
protected void onCreate() {
|
@Column(name = "updated_at", nullable = false)
|
||||||
createdAt = LocalDateTime.now();
|
|
||||||
updatedAt = LocalDateTime.now();
|
|
||||||
if (status == null) {
|
|
||||||
status = EmailStatus.PENDING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Column(name = "updated_at", nullable = false, updatable = false)
|
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
// Getters and Setters
|
@PrePersist
|
||||||
public Long getId() {
|
protected void onCreate() {
|
||||||
return id;
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
createdAt = now;
|
||||||
|
updatedAt = now;
|
||||||
|
|
||||||
|
if (status == null) status = EmailStatus.PENDING;
|
||||||
|
if (openCount == null) openCount = 0;
|
||||||
|
if (clickCount == null) clickCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setId(Long id) {
|
@PreUpdate
|
||||||
this.id = id;
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getRecipient() {
|
// ===== Getters / Setters =====
|
||||||
return recipient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRecipient(String recipient) {
|
public Long getId() { return id; }
|
||||||
this.recipient = recipient;
|
public void setId(Long id) { this.id = id; }
|
||||||
}
|
|
||||||
|
|
||||||
public String getSubject() {
|
public String getRecipient() { return recipient; }
|
||||||
return subject;
|
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setSubject(String subject) {
|
public String getSubject() { return subject; }
|
||||||
this.subject = subject;
|
public void setSubject(String subject) { this.subject = subject; }
|
||||||
}
|
|
||||||
|
|
||||||
public String getBody() {
|
public String getBody() { return body; }
|
||||||
return body;
|
public void setBody(String body) { this.body = body; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setBody(String body) {
|
public String getTemplateKey() { return templateKey; }
|
||||||
this.body = body;
|
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getSentAt() {
|
public EmailStatus getStatus() { return status; }
|
||||||
return sentAt;
|
public void setStatus(EmailStatus status) { this.status = status; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setSentAt(LocalDateTime sentAt) {
|
public LocalDateTime getSentAt() { return sentAt; }
|
||||||
this.sentAt = sentAt;
|
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public EmailStatus getStatus() {
|
public String getErrorMessage() { return errorMessage; }
|
||||||
return status;
|
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setStatus(EmailStatus status) {
|
public LocalDateTime getOpenedAt() { return openedAt; }
|
||||||
this.status = status;
|
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public String getErrorMessage() {
|
public Integer getOpenCount() { return openCount; }
|
||||||
return errorMessage;
|
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setErrorMessage(String errorMessage) {
|
public LocalDateTime getClickedAt() { return clickedAt; }
|
||||||
this.errorMessage = errorMessage;
|
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public Integer getClickCount() { return clickCount; }
|
||||||
return createdAt;
|
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setCreatedAt(LocalDateTime createdAt) {
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
this.createdAt = createdAt;
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getUpdatedAt() {
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
return updatedAt;
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
|
||||||
this.updatedAt = updatedAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package group.goforward.battlbuilder.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "email_template")
|
||||||
|
public class EmailTemplate {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "template_key", nullable = false, unique = true, length = 100)
|
||||||
|
private String templateKey;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 150)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "subject", nullable = false, length = 255)
|
||||||
|
private String subject;
|
||||||
|
|
||||||
|
@Column(name = "mjml", columnDefinition = "text")
|
||||||
|
private String mjml;
|
||||||
|
|
||||||
|
@Column(name = "html_body", nullable = false, columnDefinition = "text")
|
||||||
|
private String htmlBody;
|
||||||
|
|
||||||
|
@Column(name = "text_body", columnDefinition = "text")
|
||||||
|
private String textBody;
|
||||||
|
|
||||||
|
@Column(name = "enabled", nullable = false)
|
||||||
|
private Boolean enabled = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (createdAt == null) createdAt = now;
|
||||||
|
if (updatedAt == null) updatedAt = now;
|
||||||
|
if (enabled == null) enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- getters/setters ---
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
|
||||||
|
public String getTemplateKey() { return templateKey; }
|
||||||
|
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getSubject() { return subject; }
|
||||||
|
public void setSubject(String subject) { this.subject = subject; }
|
||||||
|
|
||||||
|
public String getMjml() { return mjml; }
|
||||||
|
public void setMjml(String mjml) { this.mjml = mjml; }
|
||||||
|
|
||||||
|
public String getHtmlBody() { return htmlBody; }
|
||||||
|
public void setHtmlBody(String htmlBody) { this.htmlBody = htmlBody; }
|
||||||
|
|
||||||
|
public String getTextBody() { return textBody; }
|
||||||
|
public void setTextBody(String textBody) { this.textBody = textBody; }
|
||||||
|
|
||||||
|
public Boolean getEnabled() { return enabled; }
|
||||||
|
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package group.goforward.battlbuilder.repos;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.EmailTemplate;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
|
||||||
|
|
||||||
|
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import group.goforward.battlbuilder.model.AuthToken;
|
|||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repos.AuthTokenRepository;
|
import group.goforward.battlbuilder.repos.AuthTokenRepository;
|
||||||
import group.goforward.battlbuilder.repos.UserRepository;
|
import group.goforward.battlbuilder.repos.UserRepository;
|
||||||
import group.goforward.battlbuilder.services.utils.EmailService;
|
import group.goforward.battlbuilder.services.utils.TemplatedEmailService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -14,13 +14,14 @@ import java.security.SecureRandom;
|
|||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BetaInviteService {
|
public class BetaInviteService {
|
||||||
|
|
||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final AuthTokenRepository tokens;
|
private final AuthTokenRepository tokens;
|
||||||
private final EmailService emailService;
|
private final TemplatedEmailService templatedEmailService;
|
||||||
|
|
||||||
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
@Value("${app.publicBaseUrl:http://localhost:3000}")
|
||||||
private String publicBaseUrl;
|
private String publicBaseUrl;
|
||||||
@@ -30,15 +31,19 @@ public class BetaInviteService {
|
|||||||
|
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
public BetaInviteService(UserRepository users, AuthTokenRepository tokens, EmailService emailService) {
|
// ✅ Constructor injection
|
||||||
|
public BetaInviteService(
|
||||||
|
UserRepository users,
|
||||||
|
AuthTokenRepository tokens,
|
||||||
|
TemplatedEmailService templatedEmailService
|
||||||
|
) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.tokens = tokens;
|
this.tokens = tokens;
|
||||||
this.emailService = emailService;
|
this.templatedEmailService = templatedEmailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
||||||
// You may need to adjust this query depending on your repo methods.
|
|
||||||
// See NOTE below if you don’t have this finder.
|
|
||||||
List<User> betaUsers = (limit > 0)
|
List<User> betaUsers = (limit > 0)
|
||||||
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
|
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
|
||||||
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||||
@@ -49,23 +54,24 @@ public class BetaInviteService {
|
|||||||
String email = user.getEmail();
|
String email = user.getEmail();
|
||||||
|
|
||||||
String magicToken = generateToken();
|
String magicToken = generateToken();
|
||||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken,
|
saveToken(
|
||||||
OffsetDateTime.now().plusMinutes(tokenMinutes));
|
email,
|
||||||
|
AuthToken.TokenType.MAGIC_LOGIN,
|
||||||
|
magicToken,
|
||||||
|
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
||||||
|
);
|
||||||
|
|
||||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||||
|
|
||||||
String subject = "Your Battl Builders beta access link";
|
|
||||||
String body = """
|
|
||||||
You’re in.
|
|
||||||
|
|
||||||
Here’s your secure sign-in link (expires in %d minutes):
|
|
||||||
%s
|
|
||||||
|
|
||||||
If you didn’t request this, you can ignore this email.
|
|
||||||
""".formatted(tokenMinutes, magicUrl);
|
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
emailService.sendEmail(email, subject, body);
|
templatedEmailService.send(
|
||||||
|
"beta_invite", // template_key
|
||||||
|
email,
|
||||||
|
Map.of(
|
||||||
|
"minutes", String.valueOf(tokenMinutes),
|
||||||
|
"magicUrl", magicUrl
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sent++;
|
sent++;
|
||||||
@@ -74,7 +80,12 @@ public class BetaInviteService {
|
|||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
private void saveToken(
|
||||||
|
String email,
|
||||||
|
AuthToken.TokenType type,
|
||||||
|
String token,
|
||||||
|
OffsetDateTime expiresAt
|
||||||
|
) {
|
||||||
AuthToken t = new AuthToken();
|
AuthToken t = new AuthToken();
|
||||||
t.setEmail(email);
|
t.setEmail(email);
|
||||||
t.setType(type);
|
t.setType(type);
|
||||||
@@ -93,7 +104,9 @@ public class BetaInviteService {
|
|||||||
private String hashToken(String token) {
|
private String hashToken(String token) {
|
||||||
try {
|
try {
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
|
byte[] hashed = md.digest(
|
||||||
|
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
|
||||||
|
);
|
||||||
return HexFormat.of().formatHex(hashed);
|
return HexFormat.of().formatHex(hashed);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Failed to hash token", e);
|
throw new RuntimeException("Failed to hash token", e);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package group.goforward.battlbuilder.services.utils;
|
package group.goforward.battlbuilder.services.utils;
|
||||||
|
|
||||||
import aj.org.objectweb.asm.commons.Remapper;
|
|
||||||
import group.goforward.battlbuilder.model.EmailRequest;
|
import group.goforward.battlbuilder.model.EmailRequest;
|
||||||
|
|
||||||
public interface EmailService {
|
public interface EmailService {
|
||||||
EmailRequest sendEmail(String recipient, String subject, String body);
|
EmailRequest sendEmail(String recipient, String subject, String body);
|
||||||
|
|
||||||
|
EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody);
|
||||||
|
|
||||||
|
EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody, String templateKey);
|
||||||
|
|
||||||
void deleteById(Integer id);
|
void deleteById(Integer id);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package group.goforward.battlbuilder.services.utils;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class TemplateRenderer {
|
||||||
|
private TemplateRenderer() {}
|
||||||
|
|
||||||
|
public static String render(String template, Map<String, String> vars) {
|
||||||
|
String out = template;
|
||||||
|
for (var e : vars.entrySet()) {
|
||||||
|
out = out.replace("{{" + e.getKey() + "}}", e.getValue() == null ? "" : e.getValue());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package group.goforward.battlbuilder.services.utils;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.EmailRequest;
|
||||||
|
import group.goforward.battlbuilder.model.EmailTemplate;
|
||||||
|
import group.goforward.battlbuilder.repos.EmailTemplateRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TemplatedEmailService {
|
||||||
|
|
||||||
|
private final EmailTemplateRepository templates;
|
||||||
|
private final EmailService emailService;
|
||||||
|
|
||||||
|
public TemplatedEmailService(EmailTemplateRepository templates, EmailService emailService) {
|
||||||
|
this.templates = templates;
|
||||||
|
this.emailService = emailService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmailRequest send(String templateKey, String to, Map<String, String> vars) {
|
||||||
|
EmailTemplate t = templates.findByTemplateKeyAndEnabledTrue(templateKey)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Missing/disabled email template: " + templateKey));
|
||||||
|
|
||||||
|
String subject = TemplateRenderer.render(t.getSubject(), vars);
|
||||||
|
String html = TemplateRenderer.render(t.getHtmlBody(), vars);
|
||||||
|
String text = t.getTextBody() == null ? null : TemplateRenderer.render(t.getTextBody(), vars);
|
||||||
|
|
||||||
|
// ✅ template_key persisted inside EmailService (no double-save)
|
||||||
|
return emailService.sendEmailHtml(to, subject, html, text, templateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,38 +31,81 @@ public class EmailServiceImpl implements EmailService {
|
|||||||
@Value("${app.email.outbound-enabled:true}")
|
@Value("${app.email.outbound-enabled:true}")
|
||||||
private boolean outboundEnabled;
|
private boolean outboundEnabled;
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an email and persists send status.
|
|
||||||
* Uses multipart=true to avoid MimeMessageHelper errors when setting text.
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public EmailRequest sendEmail(String recipient, String subject, String body) {
|
public EmailRequest sendEmail(String recipient, String subject, String body) {
|
||||||
|
EmailRequest req = new EmailRequest();
|
||||||
|
req.setRecipient(recipient);
|
||||||
|
req.setSubject(subject);
|
||||||
|
req.setBody(body);
|
||||||
|
req.setStatus(EmailStatus.PENDING);
|
||||||
|
|
||||||
// Persist initial request
|
// Capture-only mode: store but don’t send
|
||||||
EmailRequest emailRequest = new EmailRequest();
|
|
||||||
emailRequest.setRecipient(recipient);
|
|
||||||
emailRequest.setSubject(subject);
|
|
||||||
emailRequest.setBody(body);
|
|
||||||
emailRequest.setStatus(EmailStatus.PENDING);
|
|
||||||
emailRequest.setCreatedAt(LocalDateTime.now());
|
|
||||||
|
|
||||||
emailRequest = emailRequestRepository.save(emailRequest);
|
|
||||||
|
|
||||||
// ✅ Capture-only mode: record it, but don’t send
|
|
||||||
if (!outboundEnabled) {
|
if (!outboundEnabled) {
|
||||||
emailRequest.setStatus(EmailStatus.FAILED);
|
req.setStatus(EmailStatus.PENDING);
|
||||||
emailRequest.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)");
|
req.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)");
|
||||||
return emailRequestRepository.save(emailRequest);
|
return emailRequestRepository.save(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MimeMessage message = mailSender.createMimeMessage();
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
|
||||||
// ✅ multipart=true fixes "Not in multipart mode" errors
|
|
||||||
MimeMessageHelper helper = new MimeMessageHelper(
|
MimeMessageHelper helper = new MimeMessageHelper(
|
||||||
message,
|
message,
|
||||||
true, // multipart
|
true,
|
||||||
|
StandardCharsets.UTF_8.name()
|
||||||
|
);
|
||||||
|
|
||||||
|
helper.setFrom(fromEmail);
|
||||||
|
helper.setTo(recipient);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(body, false);
|
||||||
|
|
||||||
|
mailSender.send(message);
|
||||||
|
|
||||||
|
req.setStatus(EmailStatus.SENT);
|
||||||
|
req.setSentAt(LocalDateTime.now());
|
||||||
|
} catch (Exception e) {
|
||||||
|
req.setStatus(EmailStatus.FAILED);
|
||||||
|
req.setErrorMessage(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Single INSERT (no update spam)
|
||||||
|
return emailRequestRepository.save(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(Integer id) {
|
||||||
|
emailRequestRepository.deleteById(id.longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody) {
|
||||||
|
return sendEmailHtml(recipient, subject, htmlBody, textBody, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public EmailRequest sendEmailHtml(String recipient, String subject, String htmlBody, String textBody, String templateKey) {
|
||||||
|
|
||||||
|
EmailRequest req = new EmailRequest();
|
||||||
|
req.setRecipient(recipient);
|
||||||
|
req.setSubject(subject);
|
||||||
|
req.setBody(htmlBody); // storing HTML for now
|
||||||
|
req.setTemplateKey(templateKey);
|
||||||
|
req.setStatus(EmailStatus.PENDING);
|
||||||
|
|
||||||
|
// Capture-only mode: store but don’t send
|
||||||
|
if (!outboundEnabled) {
|
||||||
|
req.setStatus(EmailStatus.PENDING);
|
||||||
|
req.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)");
|
||||||
|
return emailRequestRepository.save(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(
|
||||||
|
message,
|
||||||
|
true,
|
||||||
StandardCharsets.UTF_8.name()
|
StandardCharsets.UTF_8.name()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,24 +113,19 @@ public class EmailServiceImpl implements EmailService {
|
|||||||
helper.setTo(recipient);
|
helper.setTo(recipient);
|
||||||
helper.setSubject(subject);
|
helper.setSubject(subject);
|
||||||
|
|
||||||
// Plain text email (safe + deliverable)
|
// plain + html (best practice)
|
||||||
helper.setText(body, false);
|
helper.setText(textBody != null ? textBody : "", htmlBody);
|
||||||
|
|
||||||
mailSender.send(message);
|
mailSender.send(message);
|
||||||
|
|
||||||
emailRequest.setStatus(EmailStatus.SENT);
|
req.setStatus(EmailStatus.SENT);
|
||||||
emailRequest.setSentAt(LocalDateTime.now());
|
req.setSentAt(LocalDateTime.now());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
emailRequest.setStatus(EmailStatus.FAILED);
|
req.setStatus(EmailStatus.FAILED);
|
||||||
emailRequest.setErrorMessage(e.getMessage());
|
req.setErrorMessage(e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return emailRequestRepository.save(emailRequest);
|
// ✅ Single INSERT (no extra UPDATEs)
|
||||||
}
|
return emailRequestRepository.save(req);
|
||||||
|
|
||||||
@Override
|
|
||||||
public void deleteById(Integer id) {
|
|
||||||
emailRequestRepository.deleteById(id.longValue());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ security.jwt.access-token-days=30
|
|||||||
|
|
||||||
# Beta Invite Email Toggle
|
# Beta Invite Email Toggle
|
||||||
app.beta.captureOnly=true
|
app.beta.captureOnly=true
|
||||||
app.email.outbound-enabled=false
|
app.email.outbound-enabled=true
|
||||||
|
|
||||||
# CLI invite runner (off by default)
|
# CLI invite runner (off by default)
|
||||||
app.beta.invite.run=false
|
app.beta.invite.run=false
|
||||||
|
|||||||
Reference in New Issue
Block a user