Skip to content

Commit 72f9ff0

Browse files
mpgecursoragent
andauthored
feat(newsletters): full HTTP layer + engine services (Spring) (#70)
* feat(newsletter): add retry column, permissions seed, and config Co-authored-by: Cursor <cursoragent@cursor.com> * feat(newsletter): add repositories and engine services Co-authored-by: Cursor <cursoragent@cursor.com> * feat(newsletter): add HTTP controllers, scheduler, and security Co-authored-by: Cursor <cursoragent@cursor.com> * test(newsletter): add engine and public controller coverage Co-authored-by: Cursor <cursoragent@cursor.com> * fix(newsletter): use Java null-check instead of ?? operator in settings controller * fix(newsletter): add 1-arg redirect overload + drop setId on preview delivery * fix(newsletter): add setId to NewsletterDelivery for test construction * fix(newsletter): repair 4 test-harness issues (WebMvcTest ApiTokenService bean, stateful bounce-store mock, lenient strict-stubbing) * Push newsletter segment filters to the database via JPA Specifications. Avoid loading the full contacts table on every dynamic-list resolve, count, or sendable query. Column names remain allowlisted before mapping to entity paths; metadata rules use bounded JSON LIKE predicates. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Matt Gros <mpge@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6c2e3cf commit 72f9ff0

33 files changed

Lines changed: 3260 additions & 2 deletions

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dependencies {
4646
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
4747

4848
testImplementation("org.springframework.boot:spring-boot-starter-test")
49+
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
4950
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
5051
testImplementation("org.springframework.security:spring-security-test")
5152
}

src/main/java/dev/escalated/config/EscalatedProperties.java

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class EscalatedProperties {
2121
private EmailProperties email = new EmailProperties();
2222
private List<TicketActionProperties> ticketActions = new ArrayList<>();
2323
private TicketSubjectsProperties ticketSubjects = new TicketSubjectsProperties();
24+
private NewslettersProperties newsletters = new NewslettersProperties();
2425

2526
public boolean isEnabled() {
2627
return enabled;
@@ -126,6 +127,14 @@ public void setTicketSubjects(TicketSubjectsProperties ticketSubjects) {
126127
this.ticketSubjects = ticketSubjects;
127128
}
128129

130+
public NewslettersProperties getNewsletters() {
131+
return newsletters;
132+
}
133+
134+
public void setNewsletters(NewslettersProperties newsletters) {
135+
this.newsletters = newsletters;
136+
}
137+
129138
/**
130139
* Host-app models a ticket can be *about* (Project, Customer, asset, …).
131140
* {@code types} is the allowlist the agent/admin API may attach; leave empty
@@ -337,4 +346,143 @@ public void setInboundSecret(String inboundSecret) {
337346
this.inboundSecret = inboundSecret;
338347
}
339348
}
349+
350+
/** Newsletter broadcast feature (off by default). */
351+
public static class NewslettersProperties {
352+
private boolean enabled = false;
353+
private String appUrl = "http://localhost";
354+
private String defaultFrom;
355+
private String defaultReplyTo;
356+
private String defaultTheme = "default";
357+
private int rateLimitPerMinute = 60;
358+
private int batchSize = 50;
359+
private boolean trackingEnabled = true;
360+
private double autoPauseBounceRate = 0.05;
361+
private int autoPauseThreshold = 100;
362+
private int claimTimeoutMinutes = 10;
363+
private String brandAccent = "#2563eb";
364+
private String brandLogoUrl;
365+
private String brandPhysicalAddress;
366+
private String themesDir = "";
367+
368+
public boolean isEnabled() {
369+
return enabled;
370+
}
371+
372+
public void setEnabled(boolean enabled) {
373+
this.enabled = enabled;
374+
}
375+
376+
public String getAppUrl() {
377+
return appUrl;
378+
}
379+
380+
public void setAppUrl(String appUrl) {
381+
this.appUrl = appUrl;
382+
}
383+
384+
public String getDefaultFrom() {
385+
return defaultFrom;
386+
}
387+
388+
public void setDefaultFrom(String defaultFrom) {
389+
this.defaultFrom = defaultFrom;
390+
}
391+
392+
public String getDefaultReplyTo() {
393+
return defaultReplyTo;
394+
}
395+
396+
public void setDefaultReplyTo(String defaultReplyTo) {
397+
this.defaultReplyTo = defaultReplyTo;
398+
}
399+
400+
public String getDefaultTheme() {
401+
return defaultTheme;
402+
}
403+
404+
public void setDefaultTheme(String defaultTheme) {
405+
this.defaultTheme = defaultTheme;
406+
}
407+
408+
public int getRateLimitPerMinute() {
409+
return rateLimitPerMinute;
410+
}
411+
412+
public void setRateLimitPerMinute(int rateLimitPerMinute) {
413+
this.rateLimitPerMinute = rateLimitPerMinute;
414+
}
415+
416+
public int getBatchSize() {
417+
return batchSize;
418+
}
419+
420+
public void setBatchSize(int batchSize) {
421+
this.batchSize = batchSize;
422+
}
423+
424+
public boolean isTrackingEnabled() {
425+
return trackingEnabled;
426+
}
427+
428+
public void setTrackingEnabled(boolean trackingEnabled) {
429+
this.trackingEnabled = trackingEnabled;
430+
}
431+
432+
public double getAutoPauseBounceRate() {
433+
return autoPauseBounceRate;
434+
}
435+
436+
public void setAutoPauseBounceRate(double autoPauseBounceRate) {
437+
this.autoPauseBounceRate = autoPauseBounceRate;
438+
}
439+
440+
public int getAutoPauseThreshold() {
441+
return autoPauseThreshold;
442+
}
443+
444+
public void setAutoPauseThreshold(int autoPauseThreshold) {
445+
this.autoPauseThreshold = autoPauseThreshold;
446+
}
447+
448+
public int getClaimTimeoutMinutes() {
449+
return claimTimeoutMinutes;
450+
}
451+
452+
public void setClaimTimeoutMinutes(int claimTimeoutMinutes) {
453+
this.claimTimeoutMinutes = claimTimeoutMinutes;
454+
}
455+
456+
public String getBrandAccent() {
457+
return brandAccent;
458+
}
459+
460+
public void setBrandAccent(String brandAccent) {
461+
this.brandAccent = brandAccent;
462+
}
463+
464+
public String getBrandLogoUrl() {
465+
return brandLogoUrl;
466+
}
467+
468+
public void setBrandLogoUrl(String brandLogoUrl) {
469+
this.brandLogoUrl = brandLogoUrl;
470+
}
471+
472+
public String getBrandPhysicalAddress() {
473+
return brandPhysicalAddress;
474+
}
475+
476+
public void setBrandPhysicalAddress(String brandPhysicalAddress) {
477+
this.brandPhysicalAddress = brandPhysicalAddress;
478+
}
479+
480+
public String getThemesDir() {
481+
return themesDir;
482+
}
483+
484+
public void setThemesDir(String themesDir) {
485+
this.themesDir = themesDir;
486+
}
487+
}
340488
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dev.escalated.config;
2+
3+
import dev.escalated.services.newsletter.NewsletterRenderer;
4+
import java.nio.file.Path;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.core.io.ResourceLoader;
10+
11+
@Configuration
12+
@ConditionalOnProperty(prefix = "escalated.newsletters", name = "enabled", havingValue = "true")
13+
public class NewsletterConfiguration {
14+
15+
@Bean
16+
public NewsletterRenderer newsletterRenderer(
17+
EscalatedProperties properties,
18+
ResourceLoader resourceLoader,
19+
@Value("${spring.application.name:Support}") String appName) {
20+
EscalatedProperties.NewslettersProperties newsletters = properties.getNewsletters();
21+
NewsletterRenderer.Options opts = new NewsletterRenderer.Options();
22+
opts.baseUrl = newsletters.getAppUrl();
23+
opts.defaultTheme = newsletters.getDefaultTheme();
24+
opts.trackingEnabled = newsletters.isTrackingEnabled();
25+
opts.brandAccent = newsletters.getBrandAccent();
26+
opts.brandLogoUrl = newsletters.getBrandLogoUrl();
27+
opts.brandPhysicalAddress = newsletters.getBrandPhysicalAddress();
28+
opts.brandName = appName;
29+
30+
String configured = newsletters.getThemesDir();
31+
if (configured != null && !configured.isBlank()) {
32+
opts.themesDir = configured;
33+
} else {
34+
try {
35+
opts.themesDir = Path.of(
36+
resourceLoader
37+
.getResource("classpath:templates/escalated/newsletter_themes/")
38+
.getURI())
39+
.toString();
40+
} catch (Exception ex) {
41+
opts.themesDir = "templates/escalated/newsletter_themes";
42+
}
43+
}
44+
return new NewsletterRenderer(opts);
45+
}
46+
}

0 commit comments

Comments
 (0)