diff --git a/.github/labeler-config.yml b/.github/labeler-config.yml index a0a634840..bb52c7b85 100644 --- a/.github/labeler-config.yml +++ b/.github/labeler-config.yml @@ -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' diff --git a/build.gradle b/build.gradle index 76277cc99..e91f0a467 100644 --- a/build.gradle +++ b/build.gradle @@ -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" diff --git a/src/main/java/stirling/software/SPDF/config/security/mail/EmailService.java b/src/main/java/stirling/software/SPDF/config/security/mail/EmailService.java new file mode 100644 index 000000000..8939fbab6 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/mail/EmailService.java @@ -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); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/mail/MailConfig.java b/src/main/java/stirling/software/SPDF/config/security/mail/MailConfig.java new file mode 100644 index 000000000..68c2fe35d --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/mail/MailConfig.java @@ -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; + } +} diff --git a/src/main/java/stirling/software/SPDF/controller/api/EmailController.java b/src/main/java/stirling/software/SPDF/controller/api/EmailController.java new file mode 100644 index 000000000..3b91368ef --- /dev/null +++ b/src/main/java/stirling/software/SPDF/controller/api/EmailController.java @@ -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 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); + } + } +} diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index d42429619..82a17ff2c 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -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; diff --git a/src/main/java/stirling/software/SPDF/model/api/Email.java b/src/main/java/stirling/software/SPDF/model/api/Email.java new file mode 100644 index 000000000..21b5152e5 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/model/api/Email.java @@ -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 website.

Please do" + + " not reply directly to this email.") + private String body; +} diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 1c3ee32ae..201f875dd 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -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 diff --git a/src/test/java/stirling/software/SPDF/config/security/mail/EmailServiceTest.java b/src/test/java/stirling/software/SPDF/config/security/mail/EmailServiceTest.java new file mode 100644 index 000000000..63c38e9c3 --- /dev/null +++ b/src/test/java/stirling/software/SPDF/config/security/mail/EmailServiceTest.java @@ -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); + } +} diff --git a/src/test/java/stirling/software/SPDF/controller/api/EmailControllerTest.java b/src/test/java/stirling/software/SPDF/controller/api/EmailControllerTest.java new file mode 100644 index 000000000..25aa89479 --- /dev/null +++ b/src/test/java/stirling/software/SPDF/controller/api/EmailControllerTest.java @@ -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")); + } +}