feat(database): add email notifications for backups/imports & backup verification (#4253)

# Description of Changes

**What was changed**
- Added enterprise configuration for database-related email
notifications:
-
`premium.enterpriseFeatures.databaseNotifications.backups.successful|failed`
-
`premium.enterpriseFeatures.databaseNotifications.imports.successful|failed`
- Extended `ApplicationProperties` and `settings.yml.template`
accordingly.
- Introduced `DatabaseNotificationServiceInterface` and
`DatabaseNotificationService` to centralize and gate notification
sending (EE-only, respects `mail.enabled`).
- Wired notifications into `DatabaseService`:
  - Sends emails on successful/failed **backups** and **imports**.
  - Added backup verification step:
    - Compute SHA‑256 checksum for backup files.
- Validate backup by loading it into an in‑memory H2 instance
(`RUNSCRIPT`) before using it.
    - Abort import if verification fails.
- Enhanced `EmailService`:
- Added `sendSimpleMail(String to, String subject, String body)` (async)
for lightweight notifications.
  - Added debug logging for successful sends.
- Minor refactors and improved logging around backup/export/import
flows.

**Why the change was made**
- Provide admins with timely, configurable notifications about critical
database operations (backups/imports).
- Increase reliability by verifying backup integrity before execution,
reducing risk from corrupted or incomplete scripts.
- Keep configuration explicit and self-documenting via new keys in
`settings.yml.template`.

---

## 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/devGuide/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/devGuide/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/devGuide/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/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-10-30 00:18:54 +01:00 committed by GitHub
parent 30d83ec896
commit 6cc3494e62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 211 additions and 1 deletions

View File

@ -595,6 +595,25 @@ public class ApplicationProperties {
public static class EnterpriseFeatures {
private PersistentMetrics persistentMetrics = new PersistentMetrics();
private Audit audit = new Audit();
private DatabaseNotifications databaseNotifications = new DatabaseNotifications();
@Data
public static class DatabaseNotifications {
private Backup backups = new Backup();
private Imports imports = new Imports();
@Data
public static class Backup {
private boolean successful = false;
private boolean failed = false;
}
@Data
public static class Imports {
private boolean successful = false;
private boolean failed = false;
}
}
@Data
public static class Audit {

View File

@ -85,6 +85,13 @@ premium:
enabled: true # Enable audit logging
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
retentionDays: 90 # Number of days to retain audit logs
databaseNotifications:
backups:
successful: false # set to 'true' to enable email notifications for successful database backups
failed: false # set to 'true' to enable email notifications for failed database backups
imports:
successful: false # set to 'true' to enable email notifications for successful database imports
failed: false # set to 'true' to enable email notifications for failed database imports
mail:
enabled: false # set to 'true' to enable sending emails

View File

@ -0,0 +1,11 @@
package stirling.software.proprietary.security.database;
public interface DatabaseNotificationServiceInterface {
void notifyBackupsSuccess(String subject, String message);
void notifyBackupsFailure(String subject, String message);
void notifyImportsSuccess(String subject, String message);
void notifyImportsFailure(String subject, String message);
}

View File

@ -0,0 +1,75 @@
package stirling.software.proprietary.security.database.service;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.mail.MessagingException;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium.EnterpriseFeatures.DatabaseNotifications;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
import stirling.software.proprietary.security.service.EmailService;
@Service
@Slf4j
public class DatabaseNotificationService implements DatabaseNotificationServiceInterface {
private final Optional<EmailService> emailService;
private final ApplicationProperties props;
private final boolean runningEE;
private DatabaseNotifications notifications;
DatabaseNotificationService(
Optional<EmailService> emailService,
ApplicationProperties props,
@Qualifier("runningEE") boolean runningEE) {
this.emailService = emailService;
this.props = props;
this.runningEE = runningEE;
notifications = props.getPremium().getEnterpriseFeatures().getDatabaseNotifications();
}
@Override
public void notifyBackupsSuccess(String subject, String message) {
if (notifications.getBackups().isSuccessful() && runningEE) {
sendMail(subject, message);
}
}
@Override
public void notifyBackupsFailure(String subject, String message) {
if (notifications.getBackups().isFailed() && runningEE) {
sendMail(subject, message);
}
}
@Override
public void notifyImportsSuccess(String subject, String message) {
if (notifications.getImports().isSuccessful() && runningEE) {
sendMail(subject, message);
}
}
@Override
public void notifyImportsFailure(String subject, String message) {
if (notifications.getImports().isFailed() && runningEE) {
sendMail(subject, message);
}
}
private void sendMail(String subject, String message) {
emailService.ifPresent(
service -> {
try {
String to = props.getMail().getFrom();
service.sendSimpleMail(to, subject, message);
} catch (MessagingException e) {
log.error("Error sending notification email: {}", e.getMessage(), e);
}
});
}
}

View File

@ -7,7 +7,10 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@ -32,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
import stirling.software.proprietary.security.model.exception.BackupNotFoundException;
@Slf4j
@ -44,12 +48,16 @@ public class DatabaseService implements DatabaseServiceInterface {
private final ApplicationProperties.Datasource datasourceProps;
private final DataSource dataSource;
private final DatabaseNotificationServiceInterface backupNotificationService;
public DatabaseService(
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
ApplicationProperties.Datasource datasourceProps,
DataSource dataSource,
DatabaseNotificationServiceInterface backupNotificationService) {
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
this.datasourceProps = datasourceProps;
this.dataSource = dataSource;
this.backupNotificationService = backupNotificationService;
moveBackupFiles();
}
@ -172,6 +180,8 @@ public class DatabaseService implements DatabaseServiceInterface {
public boolean importDatabaseFromUI(String fileName) {
try {
importDatabaseFromUI(getBackupFilePath(fileName));
backupNotificationService.notifyImportsSuccess(
"Database import completed", "Import file: " + fileName);
return true;
} catch (IOException e) {
log.error(
@ -179,6 +189,9 @@ public class DatabaseService implements DatabaseServiceInterface {
fileName,
e.getMessage(),
e.getCause());
backupNotificationService.notifyImportsFailure(
"Database import failed",
"Import file: " + fileName + " Message: " + e.getMessage());
return false;
}
}
@ -219,16 +232,59 @@ public class DatabaseService implements DatabaseServiceInterface {
PreparedStatement stmt = conn.prepareStatement(query)) {
stmt.setString(1, insertOutputFilePath.toString());
stmt.execute();
backupNotificationService.notifyBackupsSuccess(
"Database backup export completed",
"Backup file: " + insertOutputFilePath.getFileName());
} catch (SQLException e) {
log.error("Error during database export: {}", e.getMessage(), e);
backupNotificationService.notifyBackupsFailure(
"Database backup export failed",
"Backup file: "
+ insertOutputFilePath.getFileName()
+ " Message: "
+ e.getMessage());
} catch (CannotReadScriptException e) {
log.error("Error during database export: File {} not found", insertOutputFilePath);
backupNotificationService.notifyBackupsFailure(
"Database backup export failed",
"Error during database export: File "
+ insertOutputFilePath.getFileName()
+ " not found. Message: "
+ e.getMessage());
}
log.info("Database export completed: {}", insertOutputFilePath);
verifyBackup(insertOutputFilePath);
}
}
private boolean verifyBackup(Path backupPath) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] content = Files.readAllBytes(backupPath);
String checksum = bytesToHex(digest.digest(content));
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
stmt.setString(1, backupPath.toString());
stmt.execute();
}
return true;
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
}
return false;
}
private String bytesToHex(byte[] hash) {
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
}
@Override
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
List<FileInfo> backupList = this.getBackupList();
@ -384,6 +440,12 @@ public class DatabaseService implements DatabaseServiceInterface {
*/
private void executeDatabaseScript(Path scriptPath) {
if (isH2Database()) {
if (!verifyBackup(scriptPath)) {
log.error("Backup verification failed for: {}", scriptPath);
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
}
String query = "RUNSCRIPT from ?;";
try (Connection conn = dataSource.getConnection();

View File

@ -11,6 +11,7 @@ import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.api.Email;
@ -20,6 +21,7 @@ import stirling.software.proprietary.security.model.api.Email;
* JavaMailSender to send the email and is designed to handle both the message content and file
* attachments.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
@ -72,5 +74,39 @@ public class EmailService {
// Sends the email via the configured mail sender
mailSender.send(message);
log.debug(
"Email sent successfully to {} with subject: {} body: {}",
email.getTo(),
email.getSubject(),
email.getBody());
}
/**
* Sends a simple email without attachments asynchronously.
*
* @param to the recipient address
* @param subject subject line
* @param body message body
* @throws MessagingException if sending fails or address is invalid
*/
@Async
public void sendSimpleMail(String to, String subject, String body) throws MessagingException {
if (to == null || to.trim().isEmpty()) {
throw new MessagingException("Invalid Addresses");
}
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false);
helper.addTo(to);
helper.setSubject(subject);
helper.setText(body, false);
helper.setFrom(mailProperties.getFrom());
mailSender.send(message);
log.debug(
"Simple email sent successfully to {} with subject: {} body: {}",
to,
subject,
body);
}
}