Add Email Sending Service with Attachment Support (#3455)

# Description of Changes

Please provide a summary of the changes, including:

- **What was changed**  
- Introduced a new `EmailService` for asynchronous email delivery with
attachment support.
- Added `MailConfig` to configure a `JavaMailSender` bean using SMTP
settings from `ApplicationProperties`.
- Created `EmailController` endpoint (`/api/v1/general/send-email`) to
accept multipart/form-data requests for sending emails.
- Defined an `Email` API model to encapsulate recipient, subject, body,
and file input.
- Extended `ApplicationProperties` to include a nested `Mail` class for
SMTP host, port, username/password, and sender address.
- Updated `settings.yml.template` to include SMTP configuration
placeholders.
- Enhanced `.github/labeler-config.yml` to cover all new security- and
API-related source files for automated labeling.

- **Why the change was made**  
- To enable Stirling-PDF to notify users via email—particularly useful
for sending generated PDFs or alerts—directly from the application.
- To centralize mail server configuration in application properties and
streamline onboarding for new environments.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-05-07 10:20:21 +02:00 committed by GitHub
parent 2ac606608a
commit 5b0eaec436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 406 additions and 12 deletions

View File

@ -27,18 +27,34 @@ Back End:
Security:
- changed-files:
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/interfaces/DatabaseInterface.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/security/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/EmailController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/H2SQLController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/UserController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/api/Email.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/exception/BackupNotFoundException.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/exception/NoProviderFoundExceptionjava'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/provider/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/AuthenticationType.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/BackupNotFoundException.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/ApiKeyAuthenticationToken.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/AttemptCounter.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/Authority.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/PersistentLogin.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/SessionEntity.java'
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
- any-glob-to-any-file: '.github/workflows/dependency-review.yml'
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
API:
- changed-files:
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/api/**/*'
- any-glob-to-any-file: 'scripts/png_to_webp.py'
- any-glob-to-any-file: 'split_photos.py'
- any-glob-to-any-file: '.github/workflows/swagger.yml'

View File

@ -51,16 +51,20 @@ sourceSets {
main {
java {
if (System.getenv("DOCKER_ENABLE_SECURITY") == "false") {
exclude "stirling/software/SPDF/config/interfaces/DatabaseInterface.java"
exclude "stirling/software/SPDF/config/security/**"
exclude "stirling/software/SPDF/controller/api/DatabaseController.java"
exclude "stirling/software/SPDF/controller/api/UserController.java"
exclude "stirling/software/SPDF/controller/api/EmailController.java"
exclude "stirling/software/SPDF/controller/api/H2SQLCondition.java"
exclude "stirling/software/SPDF/controller/api/UserController.java"
exclude "stirling/software/SPDF/controller/web/AccountWebController.java"
exclude "stirling/software/SPDF/controller/web/DatabaseWebController.java"
exclude "stirling/software/SPDF/model/api/Email.java"
exclude "stirling/software/SPDF/model/ApiKeyAuthenticationToken.java"
exclude "stirling/software/SPDF/model/AttemptCounter.java"
exclude "stirling/software/SPDF/model/Authority.java"
exclude "stirling/software/SPDF/model/BackupNotFoundException.java"
exclude "stirling/software/SPDF/model/exception/BackupNotFoundException.java"
exclude "stirling/software/SPDF/model/exception/NoProviderFoundException.java"
exclude "stirling/software/SPDF/model/PersistentLogin.java"
exclude "stirling/software/SPDF/model/SessionEntity.java"
exclude "stirling/software/SPDF/model/User.java"
@ -78,16 +82,8 @@ sourceSets {
java {
if (System.getenv("DOCKER_ENABLE_SECURITY") == "false") {
exclude "stirling/software/SPDF/config/security/**"
exclude "stirling/software/SPDF/controller/api/UserControllerTest.java"
exclude "stirling/software/SPDF/controller/api/DatabaseControllerTest.java"
exclude "stirling/software/SPDF/controller/web/AccountWebControllerTest.java"
exclude "stirling/software/SPDF/controller/web/DatabaseWebControllerTest.java"
exclude "stirling/software/SPDF/model/ApiKeyAuthenticationTokenTest.java"
exclude "stirling/software/SPDF/model/AttemptCounterTest.java"
exclude "stirling/software/SPDF/model/AuthorityTest.java"
exclude "stirling/software/SPDF/model/PersistentLoginTest.java"
exclude "stirling/software/SPDF/model/SessionEntityTest.java"
exclude "stirling/software/SPDF/model/UserTest.java"
exclude "stirling/software/SPDF/controller/api/EmailControllerTest.java"
exclude "stirling/software/SPDF/repository/**"
}
@ -459,6 +455,7 @@ dependencies {
implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE"
implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion"
implementation "org.springframework.boot:spring-boot-starter-mail:$springBootVersion"
implementation "org.springframework.session:spring-session-core:3.4.3"
implementation "org.springframework:spring-jdbc:6.2.6"

View File

@ -0,0 +1,63 @@
package stirling.software.SPDF.config.security.mail;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.api.Email;
/**
* Service class responsible for sending emails, including those with attachments. It uses
* JavaMailSender to send the email and is designed to handle both the message content and file
* attachments.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
public class EmailService {
private final JavaMailSender mailSender;
private final ApplicationProperties applicationProperties;
/**
* Sends an email with an attachment asynchronously. This method is annotated with @Async, which
* means it will be executed asynchronously.
*
* @param email The Email object containing the recipient, subject, body, and file attachment.
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendEmailWithAttachment(Email email) throws MessagingException {
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
MultipartFile file = email.getFileInput();
// Creates a MimeMessage to represent the email
MimeMessage message = mailSender.createMimeMessage();
// Helper class to set up the message content and attachments
MimeMessageHelper helper = new MimeMessageHelper(message, true);
// Sets the recipient, subject, body, and sender email
helper.addTo(email.getTo());
helper.setSubject(email.getSubject());
helper.setText(
email.getBody(),
true); // The "true" here indicates that the body contains HTML content.
helper.setFrom(mailProperties.getFrom());
// Adds the attachment to the email
helper.addAttachment(file.getOriginalFilename(), file);
// Sends the email via the configured mail sender
mailSender.send(message);
}
}

View File

@ -0,0 +1,54 @@
package stirling.software.SPDF.config.security.mail;
import java.util.Properties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
/**
* This configuration class provides the JavaMailSender bean, which is used to send emails. It reads
* email server settings from the configuration (ApplicationProperties) and configures the mail
* client (JavaMailSender).
*/
@Configuration
@Slf4j
@AllArgsConstructor
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
public class MailConfig {
private final ApplicationProperties applicationProperties;
@Bean
public JavaMailSender javaMailSender() {
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
// Creates a new instance of JavaMailSenderImpl, which is a Spring implementation
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
mailSender.setPort(mailProperties.getPort());
mailSender.setUsername(mailProperties.getUsername());
mailSender.setPassword(mailProperties.getPassword());
mailSender.setDefaultEncoding("UTF-8");
// Retrieves the JavaMail properties to configure additional SMTP parameters
Properties props = mailSender.getJavaMailProperties();
// Enables SMTP authentication
props.put("mail.smtp.auth", "true");
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
// Returns the configured mail sender, ready to send emails
return mailSender;
}
}

View File

@ -0,0 +1,57 @@
package stirling.software.SPDF.controller.api;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.mail.MessagingException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.mail.EmailService;
import stirling.software.SPDF.model.api.Email;
/**
* Controller for handling email-related API requests. This controller exposes an endpoint for
* sending emails with attachments.
*/
@RestController
@RequestMapping("/api/v1/general")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "General", description = "General APIs")
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
public class EmailController {
private final EmailService emailService;
/**
* Endpoint to send an email with an attachment. This method consumes a multipart/form-data
* request containing the email details and attachment.
*
* @param email The Email object containing recipient address, subject, body, and file
* attachment.
* @return ResponseEntity with success or error message.
*/
@PostMapping(consumes = "multipart/form-data", value = "/send-email")
public ResponseEntity<String> sendEmailWithAttachment(@Valid @ModelAttribute Email email) {
try {
// Calls the service to send the email with attachment
emailService.sendEmailWithAttachment(email);
return ResponseEntity.ok("Email sent successfully");
} catch (MessagingException e) {
// Catches any messaging exception (e.g., invalid email address, SMTP server issues)
String errorMsg = "Failed to send email: " + e.getMessage();
log.error(errorMsg, e); // Logging the detailed error
// Returns an error response with status 500 (Internal Server Error)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorMsg);
}
}
}

View File

@ -82,6 +82,8 @@ public class ApplicationProperties {
private Metrics metrics = new Metrics();
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Premium premium = new Premium();
private EnterpriseEdition enterpriseEdition = new EnterpriseEdition();
private AutoPipeline autoPipeline = new AutoPipeline();
@ -420,6 +422,16 @@ public class ApplicationProperties {
}
}
@Data
public static class Mail {
private boolean enabled;
private String host;
private int port;
private String username;
@ToString.Exclude private String password;
private String from;
}
@Data
public static class Premium {
private boolean enabled;

View File

@ -0,0 +1,39 @@
package stirling.software.SPDF.model.api;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
public class Email extends GeneralFile {
@Schema(
description = "The recipient's email address",
requiredMode = Schema.RequiredMode.REQUIRED,
format = "email")
private String to;
@Schema(
description = "The subject of the email",
defaultValue = "Stirling Software PDF Notification",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String subject;
@Schema(
description = "The body of the email",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue =
"This message was automatically generated by Stirling-PDF, an innovative"
+ " solution from Stirling Software. For more information, visit our <a"
+ " href=\"https://stirling-software.com\">website</a>.<br><br>Please do"
+ " not reply directly to this email.")
private String body;
}

View File

@ -76,6 +76,14 @@ premium:
apiKey: ''
appId: ''
mail:
enabled: true # set to 'true' to enable sending emails
host: smtp.example.com # SMTP server hostname
port: 587 # SMTP server port
username: '' # SMTP server username
password: '' # SMTP server password
from: '' # sender email address
legal:
termsAndConditions: https://www.stirlingpdf.com/terms-and-conditions # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirlingpdf.com/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder

View File

@ -0,0 +1,64 @@
package stirling.software.SPDF.config.security.mail;
import static org.mockito.Mockito.*;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.api.Email;
@ExtendWith(MockitoExtension.class)
public class EmailServiceTest {
@Mock
private JavaMailSender mailSender;
@Mock
private ApplicationProperties applicationProperties;
@Mock
private ApplicationProperties.Mail mailProperties;
@Mock
private MultipartFile fileInput;
@InjectMocks
private EmailService emailService;
@Test
void testSendEmailWithAttachment() throws MessagingException {
// Mock the values returned by ApplicationProperties
when(applicationProperties.getMail()).thenReturn(mailProperties);
when(mailProperties.getFrom()).thenReturn("no-reply@stirling-software.com");
// Create a mock Email object
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
// Mock MultipartFile behavior
when(fileInput.getOriginalFilename()).thenReturn("testFile.txt");
// Mock MimeMessage
MimeMessage mimeMessage = mock(MimeMessage.class);
// Configure mailSender to return the mocked MimeMessage
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// Call the service method
emailService.sendEmailWithAttachment(email);
// Verify that the email was sent using mailSender
verify(mailSender).send(mimeMessage);
}
}

View File

@ -0,0 +1,84 @@
package stirling.software.SPDF.controller.api;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import jakarta.mail.MessagingException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.config.security.mail.EmailService;
import stirling.software.SPDF.model.api.Email;
@ExtendWith(MockitoExtension.class)
public class EmailControllerTest {
private MockMvc mockMvc;
@Mock private EmailService emailService;
@InjectMocks private EmailController emailController;
@Mock private MultipartFile fileInput;
@BeforeEach
void setUp() {
// Set up the MockMvc instance for testing
mockMvc = MockMvcBuilders.standaloneSetup(emailController).build();
}
@Test
void testSendEmailWithAttachmentSuccess() throws Exception {
// Create a mock Email object
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
// Mock the service to not throw any exception
doNothing().when(emailService).sendEmailWithAttachment(any(Email.class));
// Perform the request and verify the response
mockMvc.perform(
multipart("/api/v1/general/send-email")
.file("fileInput", "dummy-content".getBytes())
.param("to", email.getTo())
.param("subject", email.getSubject())
.param("body", email.getBody()))
.andExpect(status().isOk())
.andExpect(content().string("Email sent successfully"));
}
@Test
void testSendEmailWithAttachmentFailure() throws Exception {
// Create a mock Email object
Email email = new Email();
email.setTo("test@example.com");
email.setSubject("Test Email");
email.setBody("This is a test email.");
email.setFileInput(fileInput);
// Mock the service to throw a MessagingException
doThrow(new MessagingException("Failed to send email"))
.when(emailService)
.sendEmailWithAttachment(any(Email.class));
// Perform the request and verify the response
mockMvc.perform(
multipart("/api/v1/general/send-email")
.file("fileInput", "dummy-content".getBytes())
.param("to", email.getTo())
.param("subject", email.getSubject())
.param("body", email.getBody()))
.andExpect(status().isInternalServerError())
.andExpect(content().string("Failed to send email: Failed to send email"));
}
}