wired in email templates

This commit is contained in:
2025-12-26 19:09:22 -05:00
parent 24d6cfdae3
commit d8c1cf6799
11 changed files with 384 additions and 144 deletions

View File

@@ -46,8 +46,7 @@ public class SecurityConfig {
// protected
.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)
.anyRequest().permitAll()
)

View File

@@ -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();
}
}

View File

@@ -1,24 +1,25 @@
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "email_requests")
@NamedQuery(
name = "EmailRequest.findSent",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
)
@NamedQuery(
name = "EmailRequest.findFailed",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
)
@NamedQuery(
name = "EmailRequest.findPending",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
)
@NamedQueries({
@NamedQuery(
name = "EmailRequest.findSent",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
),
@NamedQuery(
name = "EmailRequest.findFailed",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
),
@NamedQuery(
name = "EmailRequest.findPending",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
)
})
public class EmailRequest {
@Id
@@ -34,101 +35,95 @@ public class EmailRequest {
@Column(columnDefinition = "TEXT")
private String body;
@Column(name = "sent_at")
private LocalDateTime sentAt;
@Column(name = "template_key", length = 100)
private String templateKey;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EmailStatus status; // PENDING, SENT, FAILED
@Column(name = "sent_at")
private LocalDateTime sentAt;
@Column(name = "error_message")
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)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (status == null) {
status = EmailStatus.PENDING;
}
}
@Column(name = "updated_at", nullable = false, updatable = false)
// ✅ should be updatable
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
// Getters and Setters
public Long getId() {
return id;
@PrePersist
protected void onCreate() {
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) {
this.id = id;
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public String getRecipient() {
return recipient;
}
// ===== Getters / Setters =====
public void setRecipient(String recipient) {
this.recipient = recipient;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSubject() {
return subject;
}
public String getRecipient() { return recipient; }
public void setRecipient(String recipient) { this.recipient = recipient; }
public void setSubject(String subject) {
this.subject = subject;
}
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getBody() {
return body;
}
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
public void setBody(String body) {
this.body = body;
}
public String getTemplateKey() { return templateKey; }
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
public LocalDateTime getSentAt() {
return sentAt;
}
public EmailStatus getStatus() { return status; }
public void setStatus(EmailStatus status) { this.status = status; }
public void setSentAt(LocalDateTime sentAt) {
this.sentAt = sentAt;
}
public LocalDateTime getSentAt() { return sentAt; }
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
public EmailStatus getStatus() {
return status;
}
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public void setStatus(EmailStatus status) {
this.status = status;
}
public LocalDateTime getOpenedAt() { return openedAt; }
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
public String getErrorMessage() {
return errorMessage;
}
public Integer getOpenCount() { return openCount; }
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public LocalDateTime getClickedAt() { return clickedAt; }
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
public LocalDateTime getCreatedAt() {
return createdAt;
}
public Integer getClickCount() { return clickCount; }
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
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;
}
}
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -4,7 +4,7 @@ import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repos.AuthTokenRepository;
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.stereotype.Service;
@@ -14,13 +14,14 @@ import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class BetaInviteService {
private final UserRepository users;
private final AuthTokenRepository tokens;
private final EmailService emailService;
private final TemplatedEmailService templatedEmailService;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
@@ -30,15 +31,19 @@ public class BetaInviteService {
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.tokens = tokens;
this.emailService = emailService;
this.templatedEmailService = templatedEmailService;
}
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 dont have this finder.
List<User> betaUsers = (limit > 0)
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
@@ -49,23 +54,24 @@ public class BetaInviteService {
String email = user.getEmail();
String magicToken = generateToken();
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken,
OffsetDateTime.now().plusMinutes(tokenMinutes));
saveToken(
email,
AuthToken.TokenType.MAGIC_LOGIN,
magicToken,
OffsetDateTime.now().plusMinutes(tokenMinutes)
);
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
String subject = "Your Battl Builders beta access link";
String body = """
Youre in.
Heres your secure sign-in link (expires in %d minutes):
%s
If you didnt request this, you can ignore this email.
""".formatted(tokenMinutes, magicUrl);
if (!dryRun) {
emailService.sendEmail(email, subject, body);
templatedEmailService.send(
"beta_invite", // template_key
email,
Map.of(
"minutes", String.valueOf(tokenMinutes),
"magicUrl", magicUrl
)
);
}
sent++;
@@ -74,7 +80,12 @@ public class BetaInviteService {
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();
t.setEmail(email);
t.setType(type);
@@ -93,7 +104,9 @@ public class BetaInviteService {
private String hashToken(String token) {
try {
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);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);

View File

@@ -1,12 +1,13 @@
package group.goforward.battlbuilder.services.utils;
import aj.org.objectweb.asm.commons.Remapper;
import group.goforward.battlbuilder.model.EmailRequest;
public interface EmailService {
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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -31,38 +31,81 @@ public class EmailServiceImpl implements EmailService {
@Value("${app.email.outbound-enabled:true}")
private boolean outboundEnabled;
/**
* Sends an email and persists send status.
* Uses multipart=true to avoid MimeMessageHelper errors when setting text.
*/
@Override
@Transactional
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
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 dont send
// Capture-only mode: store but dont send
if (!outboundEnabled) {
emailRequest.setStatus(EmailStatus.FAILED);
emailRequest.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)");
return emailRequestRepository.save(emailRequest);
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();
// ✅ multipart=true fixes "Not in multipart mode" errors
MimeMessageHelper helper = new MimeMessageHelper(
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 dont 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()
);
@@ -70,24 +113,19 @@ public class EmailServiceImpl implements EmailService {
helper.setTo(recipient);
helper.setSubject(subject);
// Plain text email (safe + deliverable)
helper.setText(body, false);
// plain + html (best practice)
helper.setText(textBody != null ? textBody : "", htmlBody);
mailSender.send(message);
emailRequest.setStatus(EmailStatus.SENT);
emailRequest.setSentAt(LocalDateTime.now());
req.setStatus(EmailStatus.SENT);
req.setSentAt(LocalDateTime.now());
} catch (Exception e) {
emailRequest.setStatus(EmailStatus.FAILED);
emailRequest.setErrorMessage(e.getMessage());
req.setStatus(EmailStatus.FAILED);
req.setErrorMessage(e.getMessage());
}
return emailRequestRepository.save(emailRequest);
}
@Override
public void deleteById(Integer id) {
emailRequestRepository.deleteById(id.longValue());
// ✅ Single INSERT (no extra UPDATEs)
return emailRequestRepository.save(req);
}
}

View File

@@ -56,7 +56,7 @@ security.jwt.access-token-days=30
# Beta Invite Email Toggle
app.beta.captureOnly=true
app.email.outbound-enabled=false
app.email.outbound-enabled=true
# CLI invite runner (off by default)
app.beta.invite.run=false