[#7698] send system email alerts to superusers in case of an error with the automated backups

This commit is contained in:
Gani Georgiev
2026-05-26 16:54:45 +03:00
parent 40c631db32
commit d4026ce60f
4 changed files with 271 additions and 1 deletions
+3 -1
View File
@@ -1,9 +1,11 @@
## v0.39.0 (WIP)
- Fixed logs bulk selection export error.
- Added new "SQL console" section under _Settings > Debug_ allowing executing any raw SQL query from the UI ([#2236](https://github.com/pocketbase/pocketbase/issues/2236); [#7638](https://github.com/pocketbase/pocketbase/discussions/7638)).
_Note that this is intended for one-off analytic queries, the occasional `VACUUM`/`PRAGMA optimize` or debug purposes and not as the primary interface for interacting with your PocketBase data because it can break your application if not used with proper care!_
- Fixed logs bulk selection export error.
- Send system email alerts to superusers in case of an error with the automated backups ([#7698](https://github.com/pocketbase/pocketbase/issues/7698)).
- Other minor improvements (optimized logs and records list rendering, word breaking in labels, text contrast improvements, registered missing `oidc2` and `oidc3` option fields, updated default email template texts for consistency, etc.).
+13
View File
@@ -322,6 +322,19 @@ func (app *BaseApp) registerAutobackupHooks() {
slog.String("name", name),
slog.String("error", err.Error()),
)
alertError := sendSystemAlertToAllSuperusers(
app,
"Autobackup failure",
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
)
if alertError != nil {
app.Logger().Warn(
"[Backup cron] Failed to send backup error alerts",
slog.String("name", name),
slog.String("error", alertError.Error()),
)
}
}
maxKeep := app.Settings().Backups.CronMaxKeep
+131
View File
@@ -0,0 +1,131 @@
package core
import (
"bytes"
"errors"
"html"
"html/template"
"net/mail"
"github.com/pocketbase/pocketbase/tools/mailer"
)
const systemAlertHTML = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
body, html {
padding: 0;
margin: 0;
border: 0;
color: #16161a;
background: #fff;
font-size: 14px;
line-height: 20px;
font-weight: normal;
font-family: Source Sans Pro, sans-serif, emoji;
}
body {
padding: 20px 30px;
}
p {
display: block;
margin: 10px 0;
font-family: inherit;
}
small {
font-size: 12px;
line-height: 16px;
}
strong {
font-weight: bold;
}
em, i {
font-style: italic;
}
a {
color: inherit;
}
.alert {
padding: 15px;
background: #e4e8ec;
border-radius: 5px;
white-space: pre-wrap;
}
</style>
</head>
<body>
<p>{{.AppName}} system alert occurred:</p>
<p class="alert"><strong>{{.AlertDetails}}</strong></p>
<p>For more information you could explore the logs in the dashboard of your application.</p>
</body>
</html>`
// sendSystemAlertToAllSuperusers sends a system error alert to all superusers.
//
// note: unexported for now until there is clarity around the planned log level alerts.
func sendSystemAlertToAllSuperusers(app App, subject string, details string) error {
superusers, err := app.FindAllRecords(CollectionNameSuperusers)
if err != nil {
return err
}
var alertErrors []error
for _, superuser := range superusers {
err := sendSystemAlert(app, superuser, subject, details)
if err != nil {
alertErrors = append(alertErrors, err)
}
}
return errors.Join(alertErrors...)
}
// sendSystemAlert sends a system error alert to a single superuser.
//
// note: unexported for now until there is clarity around the planned log level alerts.
func sendSystemAlert(app App, superuser *Record, subject string, details string) error {
if !superuser.IsSuperuser() {
return errors.New("system alerts can be sent only to superusers")
}
if subject == "" || details == "" {
return errors.New("system alerts subject and details are required")
}
data := struct {
AppName string
AlertDetails string
}{
AppName: app.Settings().Meta.AppName,
AlertDetails: details,
}
tpl := template.New("system_alert")
var parseErr error
tpl, parseErr = tpl.Parse(systemAlertHTML)
if parseErr != nil {
return parseErr
}
var buff bytes.Buffer
executeErr := tpl.Execute(&buff, data)
if executeErr != nil {
return executeErr
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: []mail.Address{{Address: superuser.Email()}},
Subject: "[" + app.Settings().Meta.AppName + " system alert] " + html.EscapeString(subject),
HTML: buff.String(),
}
return app.NewMailClient().Send(message)
}
+124
View File
@@ -0,0 +1,124 @@
package core
import (
"os"
"strconv"
"strings"
"testing"
)
func TestSendSystemAlert(t *testing.T) {
t.Parallel()
testDataDir, err := os.MkdirTemp("", "sendSystemAlert_pb_data")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDataDir)
testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
defer testApp.ResetBootstrapState()
if err := testApp.Bootstrap(); err != nil {
t.Fatal(err)
}
if err := createTestSuperusers(testApp, 3); err != nil {
t.Fatal(err)
}
superuser, err := testApp.FindAuthRecordByEmail(CollectionNameSuperusers, "test1@example.com")
if err != nil {
t.Fatal(err)
}
var sendCalls int
testApp.OnMailerSend().BindFunc(func(e *MailerEvent) error {
sendCalls++
if !strings.Contains(e.Message.Subject, "test_subject") {
t.Fatalf("Missing %q in Message.Subject:\n%s", "test_subject", e.Message.Subject)
}
if !strings.Contains(e.Message.HTML, "test_details") {
t.Fatalf("Missing %q in Message.HTML:\n%s", "test_details", e.Message.HTML)
}
if len(e.Message.To) != 1 || e.Message.To[0].Address != "test1@example.com" {
t.Fatalf("Expected To address %q, got %v", "test1@example.com", e.Message.To)
}
return nil
})
sendSystemAlert(testApp, superuser, "test_subject", "test_details")
if sendCalls != 1 {
t.Fatalf("Expected 1 mail send call, got %d", sendCalls)
}
}
func TestSendSystemAlertToAllSuperusers(t *testing.T) {
t.Parallel()
testDataDir, err := os.MkdirTemp("", "sendSystemAlertToAllSuperusers_pb_data")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDataDir)
testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
defer testApp.ResetBootstrapState()
if err := testApp.Bootstrap(); err != nil {
t.Fatal(err)
}
if err := createTestSuperusers(testApp, 3); err != nil {
t.Fatal(err)
}
var sendCalls int
testApp.OnMailerSend().BindFunc(func(e *MailerEvent) error {
sendCalls++
if !strings.Contains(e.Message.Subject, "test_subject") {
t.Fatalf("Missing %q in Message.Subject:\n%s", "test_subject", e.Message.Subject)
}
if !strings.Contains(e.Message.HTML, "test_details") {
t.Fatalf("Missing %q in Message.HTML:\n%s", "test_details", e.Message.HTML)
}
return nil
})
sendSystemAlertToAllSuperusers(testApp, "test_subject", "test_details")
if sendCalls != 3 {
t.Fatalf("Expected 3 mail send calls, got %d", sendCalls)
}
}
func createTestSuperusers(app App, total int) error {
superusersCollection, err := app.FindCollectionByNameOrId(CollectionNameSuperusers)
if err != nil {
return err
}
for i := range total {
superuser := NewRecord(superusersCollection)
superuser.SetEmail("test" + strconv.Itoa(i+1) + "@example.com")
superuser.SetRandomPassword()
if err := app.Save(superuser); err != nil {
return err
}
}
return nil
}