diff --git a/CHANGELOG.md b/CHANGELOG.md index 123cc6ed..a5f2b219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.). diff --git a/core/base_backup.go b/core/base_backup.go index 527b07cd..12d66c10 100644 --- a/core/base_backup.go +++ b/core/base_backup.go @@ -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 diff --git a/core/system_alert.go b/core/system_alert.go new file mode 100644 index 00000000..87be2cd9 --- /dev/null +++ b/core/system_alert.go @@ -0,0 +1,131 @@ +package core + +import ( + "bytes" + "errors" + "html" + "html/template" + "net/mail" + + "github.com/pocketbase/pocketbase/tools/mailer" +) + +const systemAlertHTML = ` + + + + + + + +

{{.AppName}} system alert occurred:

+

{{.AlertDetails}}

+

For more information you could explore the logs in the dashboard of your application.

+ +` + +// 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) +} diff --git a/core/system_alert_test.go b/core/system_alert_test.go new file mode 100644 index 00000000..02307499 --- /dev/null +++ b/core/system_alert_test.go @@ -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 +}