Skip to content

Commit f9bbd17

Browse files
authored
fix: make mail truly optional and clarify quick-start docs (#310) (#311)
* fix: make mail truly optional and clarify quick-start docs (#310) MailService now degrades gracefully when no JavaMailSender bean is available (typically because spring.mail.host is unset) — operations log a warning and return instead of failing application startup. README quick-start updates: - Step 1: list spring-boot-starter-oauth2-client as required (it is unconditionally wired into the security chain) and pin spring-retry to 2.0.12 to match what the library is built against. - Step 4: clarify that mail config is genuinely optional now, with email-dependent features degrading to a warning. - Step 9: clarify the library only ships email templates; user-facing pages must come from the demo app or be supplied by the consumer. Reported in #310 by @katharinebrinker. * fix: address PR 311 review feedback - MailService: switch to @PostConstruct to cache JavaMailSender once at startup; warning logged a single time without PII instead of per-call with recipient address (GDPR/noise concern raised by Copilot review). - MailServiceTest: call init() after construction to simulate @PostConstruct; no-sender tests use a dedicated service instance. - README: clarify spring.security.oauth2.enabled is a framework property (not a standard Spring Security key) and is opt-in only.
1 parent e68a7d1 commit f9bbd17

3 files changed

Lines changed: 108 additions & 28 deletions

File tree

README.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,14 @@ Follow these steps to get up and running with the Spring User Framework in your
239239
<groupId>org.springframework.boot</groupId>
240240
<artifactId>spring-boot-starter-security</artifactId>
241241
</dependency>
242+
<dependency>
243+
<groupId>org.springframework.boot</groupId>
244+
<artifactId>spring-boot-starter-oauth2-client</artifactId>
245+
</dependency>
242246
<dependency>
243247
<groupId>org.springframework.retry</groupId>
244248
<artifactId>spring-retry</artifactId>
249+
<version>2.0.12</version>
245250
</dependency>
246251
```
247252

@@ -251,9 +256,14 @@ Follow these steps to get up and running with the Spring User Framework in your
251256
implementation 'org.springframework.boot:spring-boot-starter-mail'
252257
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
253258
implementation 'org.springframework.boot:spring-boot-starter-security'
254-
implementation 'org.springframework.retry:spring-retry'
259+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
260+
implementation 'org.springframework.retry:spring-retry:2.0.12'
255261
```
256262

263+
**Notes:**
264+
- `spring-boot-starter-oauth2-client` is required even if you don't plan to use social login. The framework's security chain wires OAuth2 user services at startup; the dependency must be on the classpath so the classes resolve. The OAuth2 login flow itself is disabled by default and opt-in — set `spring.security.oauth2.enabled=true` in your application properties only when you configure OAuth2 provider credentials (this is a framework property, not a standard Spring Security key).
265+
- `spring-retry` needs an explicit version because Spring Boot's BOM may not manage this artifact. The version shown matches what the framework is built against.
266+
257267
### Step 2: Database Configuration
258268

259269
Configure your database in `application.yml`. The framework supports all databases compatible with Spring Data JPA:
@@ -298,7 +308,7 @@ spring:
298308
299309
### Step 4: Email Configuration (Optional but Recommended)
300310
301-
For password reset and email verification features:
311+
If `spring.mail.host` is not configured, the framework starts cleanly but any feature that would send mail (password reset, registration verification) logs a warning and silently skips the send. Configure mail when you want those features to actually deliver messages:
302312

303313
```yaml
304314
spring:
@@ -384,17 +394,24 @@ public class AppUserProfile extends BaseUserProfile {
384394
- Navigate to `/user/login.html`
385395
- Use the credentials you just created
386396

387-
### Step 9: Customize Pages (Optional)
397+
### Step 9: Customize Pages (Required for user-facing pages)
388398

389-
The framework provides default HTML templates, but you can override them:
399+
The framework ships email templates (`templates/mail/*.html`) but does **not** ship the user-facing HTML pages (login, register, forgot-password, etc). You provide those yourself, typically by copying the reference set from the demo app and styling them to match your app.
390400

391-
1. **Create custom templates** in `src/main/resources/templates/user/`:
401+
1. **Grab the reference templates** from [SpringUserFrameworkDemoApp/src/main/resources/templates/user/](https://github.com/devondragon/SpringUserFrameworkDemoApp/tree/main/src/main/resources/templates/user) and drop them into `src/main/resources/templates/user/` in your project:
392402
- `login.html` - Login page
393403
- `register.html` - Registration page
394-
- `forgot-password.html` - Password reset page
395-
- And more...
404+
- `forgot-password.html` - Password reset request
405+
- `forgot-password-change.html` - Password reset form
406+
- `update-password.html` - Authenticated password change
407+
- `update-user.html` - Profile update
408+
- `registration-complete.html`, `registration-pending-verification.html`, `request-new-verification-email.html`, `forgot-password-pending-verification.html`, `delete-account.html`
409+
410+
The demo's templates use a Thymeleaf layout (`layout.html`) and shared fragments. If you don't want that, strip the `layout:decorate` / `th:fragment` references and inline the markup.
411+
412+
2. **Use your own CSS** by adding stylesheets to `src/main/resources/static/css/`.
396413

397-
2. **Use your own CSS** by adding stylesheets to `src/main/resources/static/css/`
414+
The framework only requires that the templates exist at the URLs configured under `user.security.*` (e.g. `loginPageURI`, `registrationURI`) and post back to the matching `/user/*` API endpoints; the HTML structure inside them is yours.
398415

399416
### Complete Example Configuration
400417

src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.digitalsanctuary.spring.user.mail;
22

33
import java.util.Map;
4+
import jakarta.annotation.PostConstruct;
5+
import org.springframework.beans.factory.ObjectProvider;
46
import org.springframework.beans.factory.annotation.Value;
57
import org.springframework.mail.MailException;
68
import org.springframework.mail.javamail.JavaMailSender;
@@ -17,32 +19,49 @@
1719
/**
1820
* The MailService provides outbound email sending services on top of the Spring mail framework, and leverages Thymeleaf templates for rich dynamic
1921
* emails.
22+
*
23+
* <p>Email is treated as optional: if no {@link JavaMailSender} bean is available (typically because {@code spring.mail.host} is not configured),
24+
* a single warning is logged at startup and all send operations silently no-op, so the application starts and runs normally with email-dependent
25+
* features degraded.</p>
2026
*/
2127
@Slf4j
2228
@Service
2329
public class MailService {
2430

25-
/** The mail sender. */
26-
private final JavaMailSender mailSender;
31+
private final ObjectProvider<JavaMailSender> mailSenderProvider;
2732

28-
/** The mail content builder. */
2933
private final MailContentBuilder mailContentBuilder;
3034

35+
/** Resolved once at startup; null when JavaMailSender is not configured. */
36+
private JavaMailSender resolvedSender;
37+
3138
/** The from address. */
3239
@Value("${user.mail.fromAddress}")
3340
private String fromAddress;
3441

3542
/**
3643
* Instantiates a new mail service.
3744
*
38-
* @param mailSender the mail sender
45+
* @param mailSenderProvider provider for the mail sender; may resolve to null when mail is not configured
3946
* @param mailContentBuilder the mail content builder
4047
*/
41-
public MailService(JavaMailSender mailSender, MailContentBuilder mailContentBuilder) {
42-
this.mailSender = mailSender;
48+
public MailService(ObjectProvider<JavaMailSender> mailSenderProvider, MailContentBuilder mailContentBuilder) {
49+
this.mailSenderProvider = mailSenderProvider;
4350
this.mailContentBuilder = mailContentBuilder;
4451
}
4552

53+
/**
54+
* Resolves and caches the {@link JavaMailSender} once at startup. Logs a single warning when no sender is available so operators are informed
55+
* without flooding logs during normal operation.
56+
*/
57+
@PostConstruct
58+
void init() {
59+
resolvedSender = mailSenderProvider.getIfAvailable();
60+
if (resolvedSender == null) {
61+
log.warn("JavaMailSender is not configured — email sending is disabled. Set 'spring.mail.host' to enable.");
62+
}
63+
}
64+
4665
/**
4766
* Send a simple plain text email.
4867
*
@@ -51,11 +70,14 @@ public MailService(JavaMailSender mailSender, MailContentBuilder mailContentBuil
5170
* @param text the text to include as the email message body
5271
*/
5372
@Async
54-
@Retryable(retryFor = {MailException.class}, maxAttempts = 3,
73+
@Retryable(retryFor = {MailException.class}, maxAttempts = 3,
5574
backoff = @Backoff(delay = 1000, multiplier = 2))
5675
public void sendSimpleMessage(String to, String subject, String text) {
76+
if (resolvedSender == null) {
77+
return;
78+
}
5779
log.debug("Attempting to send simple email to: {}", to);
58-
80+
5981
MimeMessagePreparator messagePreparator = mimeMessage -> {
6082
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
6183
messageHelper.setFrom(fromAddress);
@@ -64,7 +86,7 @@ public void sendSimpleMessage(String to, String subject, String text) {
6486
messageHelper.setText(text, true);
6587
};
6688

67-
mailSender.send(messagePreparator);
89+
resolvedSender.send(messagePreparator);
6890
log.debug("Successfully sent simple email to: {}", to);
6991
}
7092

@@ -77,11 +99,14 @@ public void sendSimpleMessage(String to, String subject, String text) {
7799
* @param templatePath the file name, or path and name, for the Thymeleaf template to use to build the dynamic email
78100
*/
79101
@Async
80-
@Retryable(retryFor = {MailException.class}, maxAttempts = 3,
102+
@Retryable(retryFor = {MailException.class}, maxAttempts = 3,
81103
backoff = @Backoff(delay = 1000, multiplier = 2))
82104
public void sendTemplateMessage(String to, String subject, Map<String, Object> variables, String templatePath) {
105+
if (resolvedSender == null) {
106+
return;
107+
}
83108
log.debug("Attempting to send template email to: {}, template: {}", to, templatePath);
84-
109+
85110
MimeMessagePreparator messagePreparator = mimeMessage -> {
86111
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
87112
messageHelper.setFrom(fromAddress);
@@ -92,8 +117,8 @@ public void sendTemplateMessage(String to, String subject, Map<String, Object> v
92117
String content = mailContentBuilder.build(templatePath, context);
93118
messageHelper.setText(content, true);
94119
};
95-
96-
mailSender.send(messagePreparator);
120+
121+
resolvedSender.send(messagePreparator);
97122
log.debug("Successfully sent template email to: {}", to);
98123
}
99124

@@ -107,7 +132,7 @@ public void sendTemplateMessage(String to, String subject, Map<String, Object> v
107132
*/
108133
@Recover
109134
public void recoverSendSimpleMessage(MailException ex, String to, String subject, String text) {
110-
log.error("Failed to send simple email to {} after all retry attempts. Subject: '{}'. Error: {}",
135+
log.error("Failed to send simple email to {} after all retry attempts. Subject: '{}'. Error: {}",
111136
to, subject, ex.getMessage());
112137
}
113138

@@ -121,9 +146,9 @@ public void recoverSendSimpleMessage(MailException ex, String to, String subject
121146
* @param templatePath the template path
122147
*/
123148
@Recover
124-
public void recoverSendTemplateMessage(MailException ex, String to, String subject,
149+
public void recoverSendTemplateMessage(MailException ex, String to, String subject,
125150
Map<String, Object> variables, String templatePath) {
126-
log.error("Failed to send template email to {} after all retry attempts. Subject: '{}', Template: '{}'. Error: {}",
151+
log.error("Failed to send template email to {} after all retry attempts. Subject: '{}', Template: '{}'. Error: {}",
127152
to, subject, templatePath, ex.getMessage());
128153
}
129154
}

src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
import org.junit.jupiter.api.Test;
1818
import org.junit.jupiter.api.extension.ExtendWith;
1919
import org.mockito.ArgumentCaptor;
20-
import org.mockito.InjectMocks;
2120
import org.mockito.Mock;
2221
import org.mockito.junit.jupiter.MockitoExtension;
22+
import org.springframework.beans.factory.ObjectProvider;
2323
import org.springframework.mail.MailException;
2424
import org.springframework.mail.MailSendException;
2525
import org.springframework.mail.javamail.JavaMailSender;
@@ -39,13 +39,15 @@ class MailServiceTest {
3939
@Mock
4040
private JavaMailSender mailSender;
4141

42+
@Mock
43+
private ObjectProvider<JavaMailSender> mailSenderProvider;
44+
4245
@Mock
4346
private MailContentBuilder mailContentBuilder;
4447

4548
@Mock
4649
private MimeMessage mimeMessage;
4750

48-
@InjectMocks
4951
private MailService mailService;
5052

5153
private static final String FROM_ADDRESS = "noreply@example.com";
@@ -54,9 +56,12 @@ class MailServiceTest {
5456

5557
@BeforeEach
5658
void setUp() {
57-
// Set the from address via reflection since it's a @Value field
59+
// Provider returns the mock mailSender by default.
60+
lenient().when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender);
61+
mailService = new MailService(mailSenderProvider, mailContentBuilder);
62+
mailService.init(); // simulate @PostConstruct — caches the resolved sender
5863
ReflectionTestUtils.setField(mailService, "fromAddress", FROM_ADDRESS);
59-
64+
6065
// Setup default mock behavior
6166
lenient().when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
6267
}
@@ -506,6 +511,39 @@ void shouldHandleTemplateBuilderException() throws Exception {
506511
}
507512
}
508513

514+
@Nested
515+
@DisplayName("Missing JavaMailSender Tests")
516+
class MissingMailSenderTests {
517+
518+
private MailService unconfiguredMailService;
519+
520+
@BeforeEach
521+
void setUpUnconfigured() {
522+
// Separate service instance where the sender is absent from startup.
523+
when(mailSenderProvider.getIfAvailable()).thenReturn(null);
524+
unconfiguredMailService = new MailService(mailSenderProvider, mailContentBuilder);
525+
unconfiguredMailService.init();
526+
ReflectionTestUtils.setField(unconfiguredMailService, "fromAddress", FROM_ADDRESS);
527+
}
528+
529+
@Test
530+
@DisplayName("sendSimpleMessage should no-op when JavaMailSender is not configured")
531+
void sendSimpleMessageNoOpsWhenSenderMissing() {
532+
unconfiguredMailService.sendSimpleMessage(TO_ADDRESS, SUBJECT, "Body");
533+
534+
verify(mailSender, never()).send(any(MimeMessagePreparator.class));
535+
}
536+
537+
@Test
538+
@DisplayName("sendTemplateMessage should no-op when JavaMailSender is not configured")
539+
void sendTemplateMessageNoOpsWhenSenderMissing() {
540+
unconfiguredMailService.sendTemplateMessage(TO_ADDRESS, SUBJECT, new HashMap<>(), "email/test");
541+
542+
verify(mailSender, never()).send(any(MimeMessagePreparator.class));
543+
verify(mailContentBuilder, never()).build(anyString(), any(Context.class));
544+
}
545+
}
546+
509547
@Nested
510548
@DisplayName("Integration with MimeMessageHelper Tests")
511549
class MimeMessageHelperIntegrationTests {

0 commit comments

Comments
 (0)