diff --git a/cmd/proxsave/base_dir_test.go b/cmd/proxsave/base_dir_test.go
new file mode 100644
index 00000000..4d29adca
--- /dev/null
+++ b/cmd/proxsave/base_dir_test.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func forceDetectedBaseDirForTest(t *testing.T, baseDir string) {
+ t.Helper()
+ origInfo := execInfo
+ origOnce := execInfoOnce
+
+ execPath := filepath.Join(baseDir, "build", "proxsave")
+ execInfo = ExecInfo{
+ ExecPath: execPath,
+ ExecDir: filepath.Dir(execPath),
+ BaseDir: baseDir,
+ HasBase: true,
+ }
+ execInfoOnce = sync.Once{}
+ execInfoOnce.Do(func() {})
+
+ t.Cleanup(func() {
+ execInfo = origInfo
+ execInfoOnce = origOnce
+ })
+}
diff --git a/cmd/proxsave/config_helpers.go b/cmd/proxsave/config_helpers.go
index 0d3d0ca4..bab65581 100644
--- a/cmd/proxsave/config_helpers.go
+++ b/cmd/proxsave/config_helpers.go
@@ -24,10 +24,7 @@ func resolveInstallConfigPath(configPath string) (string, error) {
return configPath, nil
}
- baseDir, ok := detectBaseDir()
- if !ok {
- return "", fmt.Errorf("unable to determine base directory for configuration")
- }
+ baseDir, _ := detectedBaseDirOrFallback()
return filepath.Join(baseDir, configPath), nil
}
@@ -52,6 +49,37 @@ func setEnvValue(template, key, value string) string {
return utils.SetEnvValue(template, key, value)
}
+func unsetEnvValue(template, key string) string {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return template
+ }
+
+ lines := strings.Split(template, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if utils.IsComment(trimmed) {
+ out = append(out, line)
+ continue
+ }
+ parts := strings.SplitN(trimmed, "=", 2)
+ if len(parts) != 2 {
+ out = append(out, line)
+ continue
+ }
+ parsedKey := strings.TrimSpace(parts[0])
+ if fields := strings.Fields(parsedKey); len(fields) >= 2 && fields[0] == "export" {
+ parsedKey = fields[1]
+ }
+ if strings.EqualFold(parsedKey, key) {
+ continue
+ }
+ out = append(out, line)
+ }
+ return strings.Join(out, "\n")
+}
+
func sanitizeEnvValue(value string) string {
value = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\x00' {
diff --git a/cmd/proxsave/decrypt_cmd.go b/cmd/proxsave/decrypt_cmd.go
index 8939f808..5533fb54 100644
--- a/cmd/proxsave/decrypt_cmd.go
+++ b/cmd/proxsave/decrypt_cmd.go
@@ -31,22 +31,12 @@ func runDecryptWorkflowOnly(ctx context.Context, configPath string, bootstrap *l
return err
}
- cfg, err := config.LoadConfig(configPath)
+ autoBaseDir, _ := detectedBaseDirOrFallback()
+ cfg, err := config.LoadConfigWithBaseDir(configPath, autoBaseDir)
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}
- autoBaseDir, _ := detectBaseDir()
- if cfg.BaseDir == "" {
- if autoBaseDir == "" {
- if _, err := os.Stat("/opt/proxsave"); err == nil {
- autoBaseDir = "/opt/proxsave"
- } else {
- autoBaseDir = "/opt/proxmox-backup"
- }
- }
- cfg.BaseDir = autoBaseDir
- }
_ = os.Setenv("BASE_DIR", cfg.BaseDir)
logLevel := cfg.DebugLevel
diff --git a/cmd/proxsave/encryption_setup.go b/cmd/proxsave/encryption_setup.go
index 0d493c36..feaa42c0 100644
--- a/cmd/proxsave/encryption_setup.go
+++ b/cmd/proxsave/encryption_setup.go
@@ -20,7 +20,8 @@ type encryptionSetupResult struct {
}
func runInitialEncryptionSetupWithUI(ctx context.Context, configPath string, ui orchestrator.AgeSetupUI) (*encryptionSetupResult, error) {
- cfg, err := config.LoadConfig(configPath)
+ baseDir, _ := detectedBaseDirOrFallback()
+ cfg, err := config.LoadConfigWithBaseDir(configPath, baseDir)
if err != nil {
return nil, fmt.Errorf("failed to reload configuration after install: %w", err)
}
diff --git a/cmd/proxsave/encryption_setup_test.go b/cmd/proxsave/encryption_setup_test.go
index 72456499..b68ba6b7 100644
--- a/cmd/proxsave/encryption_setup_test.go
+++ b/cmd/proxsave/encryption_setup_test.go
@@ -21,6 +21,7 @@ func TestRunInitialEncryptionSetupWithUIReloadsConfig(t *testing.T) {
}
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
@@ -59,6 +60,7 @@ func TestRunInitialEncryptionSetupWithUIUsesProvidedUI(t *testing.T) {
}
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
@@ -107,6 +109,7 @@ func TestRunInitialEncryptionSetupWithUIReusesExistingFileWithoutReportingWrite(
}
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
recipientPath := filepath.Join(baseDir, "identity", "age", "recipient.txt")
if err := os.MkdirAll(filepath.Dir(recipientPath), 0o700); err != nil {
t.Fatalf("MkdirAll: %v", err)
diff --git a/cmd/proxsave/helpers_test.go b/cmd/proxsave/helpers_test.go
index dd3490f2..2b93cb7b 100644
--- a/cmd/proxsave/helpers_test.go
+++ b/cmd/proxsave/helpers_test.go
@@ -741,10 +741,11 @@ func TestFeaturesNeedNetwork(t *testing.T) {
func TestDisableNetworkFeaturesForRun(t *testing.T) {
cfg := &config.Config{
- TelegramEnabled: true,
- EmailEnabled: true,
- EmailDeliveryMethod: "relay",
- CloudEnabled: true,
+ TelegramEnabled: true,
+ EmailEnabled: true,
+ EmailDeliveryMethod: "relay",
+ EmailFallbackSendmail: true,
+ CloudEnabled: true,
}
// Mock bootstrap logger (nil is ok for this test)
@@ -757,9 +758,9 @@ func TestDisableNetworkFeaturesForRun(t *testing.T) {
t.Error("CloudEnabled should be disabled")
}
- // Email with relay should be disabled
- if cfg.EmailEnabled && cfg.EmailDeliveryMethod == "relay" {
- t.Error("Email relay should be disabled")
+ // Email relay should switch to local sendmail fallback when enabled.
+ if !cfg.EmailEnabled || cfg.EmailDeliveryMethod != "sendmail" {
+ t.Errorf("Email relay should switch to sendmail fallback, enabled=%v method=%q", cfg.EmailEnabled, cfg.EmailDeliveryMethod)
}
}
diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go
index 4b8609cd..99ae3e09 100644
--- a/cmd/proxsave/install.go
+++ b/cmd/proxsave/install.go
@@ -43,7 +43,7 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots
}
configPath = resolvedPath
- baseDir := deriveBaseDirFromConfig(configPath)
+ baseDir, _ := detectedBaseDirOrFallback()
_ = os.Setenv("BASE_DIR", baseDir)
done := logging.DebugStartBootstrap(bootstrap, "install workflow (cli)", "config=%s base=%s", configPath, baseDir)
@@ -393,14 +393,6 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per
fmt.Println()
}
-func deriveBaseDirFromConfig(configPath string) string {
- baseDir := filepath.Dir(filepath.Dir(configPath))
- if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) {
- baseDir = "/opt/proxsave"
- }
- return baseDir
-}
-
func cleanupTempConfig(tmpConfigPath string) {
if tmpConfigPath == "" {
return
@@ -461,6 +453,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t
if skipConfigWizard {
return installConfigResult{SkipConfigWizard: true}, nil
}
+ template = config.RemoveRuntimeDerivedEnvKeys(template)
logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "configuring secondary storage")
if template, err = configureSecondaryStorage(ctx, reader, template); err != nil {
@@ -497,6 +490,7 @@ func runConfigWizardCLI(ctx context.Context, reader *bufio.Reader, configPath, t
}
logging.DebugStepBootstrap(bootstrap, "install config wizard (cli)", "writing configuration")
+ template = config.RemoveRuntimeDerivedEnvKeys(template)
if err := writeConfigFile(configPath, tmpConfigPath, template); err != nil {
return installConfigResult{}, err
}
@@ -750,13 +744,20 @@ func configureNotifications(ctx context.Context, reader *bufio.Reader, template
}
fmt.Println("\n--- Email ---")
- enableEmail, err := promptYesNo(ctx, reader, "Enable email notifications (central relay)? [y/N]: ", false)
+ fmt.Println("Default email delivery uses the TIS24 cloud relay, with local sendmail as failover.")
+ fmt.Println("ProxSave does not collect raw SMTP settings; choose pmf only when Proxmox Notifications is configured.")
+ enableEmail, err := promptYesNo(ctx, reader, "Enable email notifications? [y/N]: ", false)
if err != nil {
return "", err
}
if enableEmail {
+ method, err := promptEmailDeliveryMethod(ctx, reader, "relay")
+ if err != nil {
+ return "", err
+ }
template = setEnvValue(template, "EMAIL_ENABLED", "true")
- template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", "relay")
+ template = setEnvValue(template, "EMAIL_DELIVERY_METHOD", method)
+ template = unsetEnvValue(template, "EMAIL_FALLBACK_PMF")
template = setEnvValue(template, "EMAIL_FALLBACK_SENDMAIL", "true")
} else {
template = setEnvValue(template, "EMAIL_ENABLED", "false")
@@ -764,6 +765,34 @@ func configureNotifications(ctx context.Context, reader *bufio.Reader, template
return template, nil
}
+func promptEmailDeliveryMethod(ctx context.Context, reader *bufio.Reader, defaultMethod string) (string, error) {
+ defaultMethod = config.NormalizeEmailDeliveryMethod(defaultMethod)
+ if defaultMethod != "relay" && defaultMethod != "sendmail" && defaultMethod != "pmf" {
+ defaultMethod = "relay"
+ }
+
+ fmt.Println("Email delivery methods:")
+ fmt.Println(" relay TIS24 cloud relay over outbound HTTPS (default)")
+ fmt.Println(" sendmail Local /usr/sbin/sendmail (fallback/default failover; requires a local MTA)")
+ fmt.Println(" pmf Proxmox Notifications via proxmox-mail-forward (SMTP lives in Proxmox)")
+ for {
+ resp, err := promptOptional(ctx, reader, fmt.Sprintf("Email delivery method [%s]: ", defaultMethod))
+ if err != nil {
+ return "", err
+ }
+ method := defaultMethod
+ if strings.TrimSpace(resp) != "" {
+ method = config.NormalizeEmailDeliveryMethod(resp)
+ }
+ switch method {
+ case "pmf", "relay", "sendmail":
+ return method, nil
+ default:
+ fmt.Println("Please enter 'pmf', 'relay', or 'sendmail'. Aliases like 'proxmox-notifications' are accepted for pmf.")
+ }
+ }
+}
+
func configureEncryption(ctx context.Context, reader *bufio.Reader, template *string) (bool, error) {
fmt.Println("\n--- Encryption ---")
enableEncryption, err := promptYesNo(ctx, reader, "Enable backup encryption? [y/N]: ", false)
diff --git a/cmd/proxsave/install_helpers_test.go b/cmd/proxsave/install_helpers_test.go
index c224222f..5af4ef55 100644
--- a/cmd/proxsave/install_helpers_test.go
+++ b/cmd/proxsave/install_helpers_test.go
@@ -6,24 +6,64 @@ import (
"testing"
)
-func TestDeriveBaseDirFromConfig(t *testing.T) {
- tests := []struct {
- name string
- configPath string
- want string
- }{
- {"typical", "/opt/proxsave/env/backup.env", "/opt/proxsave"},
- {"root file fallback", "/backup.env", "/opt/proxsave"},
- {"relative fallback", "backup.env", "/opt/proxsave"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := deriveBaseDirFromConfig(tt.configPath); got != tt.want {
- t.Fatalf("deriveBaseDirFromConfig(%q) = %q, want %q", tt.configPath, got, tt.want)
- }
- })
- }
+func TestResolveBaseDirFromExecutablePath(t *testing.T) {
+ t.Run("standard proxsave build layout", func(t *testing.T) {
+ got, found := resolveBaseDirFromExecutablePath("/opt/proxsave/build/proxsave")
+ if !found || got != "/opt/proxsave" {
+ t.Fatalf("base dir = %q, found=%v; want /opt/proxsave true", got, found)
+ }
+ })
+
+ t.Run("standard legacy build layout", func(t *testing.T) {
+ got, found := resolveBaseDirFromExecutablePath("/opt/proxmox-backup/build/proxsave")
+ if !found || got != "/opt/proxmox-backup" {
+ t.Fatalf("base dir = %q, found=%v; want /opt/proxmox-backup true", got, found)
+ }
+ })
+
+ t.Run("symlink to installed binary", func(t *testing.T) {
+ root := t.TempDir()
+ base := filepath.Join(root, "proxsave")
+ build := filepath.Join(base, "build")
+ if err := os.MkdirAll(build, 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ target := filepath.Join(build, "proxsave")
+ if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ linkDir := filepath.Join(root, "bin")
+ if err := os.MkdirAll(linkDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll link dir: %v", err)
+ }
+ link := filepath.Join(linkDir, "proxsave")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatalf("Symlink: %v", err)
+ }
+
+ got, found := resolveBaseDirFromExecutablePath(link)
+ if !found || got != base {
+ t.Fatalf("base dir = %q, found=%v; want %q true", got, found, base)
+ }
+ })
+
+ t.Run("install marker", func(t *testing.T) {
+ base := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(base, "configs"), 0o755); err != nil {
+ t.Fatalf("MkdirAll marker: %v", err)
+ }
+ got, found := resolveBaseDirFromExecutablePath(filepath.Join(base, "bin", "proxsave"))
+ if !found || got != base {
+ t.Fatalf("base dir = %q, found=%v; want %q true", got, found, base)
+ }
+ })
+
+ t.Run("fallback", func(t *testing.T) {
+ got, found := resolveBaseDirFromExecutablePath(filepath.Join(t.TempDir(), "proxsave"))
+ if found || got != "/opt/proxsave" {
+ t.Fatalf("base dir = %q, found=%v; want /opt/proxsave false", got, found)
+ }
+ })
}
func TestCleanupTempConfig(t *testing.T) {
diff --git a/cmd/proxsave/install_test.go b/cmd/proxsave/install_test.go
index 46ae7e6b..70e54e2c 100644
--- a/cmd/proxsave/install_test.go
+++ b/cmd/proxsave/install_test.go
@@ -544,6 +544,39 @@ func TestConfigureNotifications(t *testing.T) {
}
}
+func TestConfigureNotificationsEmailDefaultsToRelaySendmailFallback(t *testing.T) {
+ var result string
+ var err error
+ ctx := context.Background()
+ reader := bufio.NewReader(strings.NewReader("n\ny\n\n"))
+ captureStdout(t, func() {
+ result, err = configureNotifications(ctx, reader, "")
+ })
+ if err != nil {
+ t.Fatalf("configureNotifications error: %v", err)
+ }
+ for _, want := range []string{
+ "TELEGRAM_ENABLED=false",
+ "EMAIL_ENABLED=true",
+ "EMAIL_DELIVERY_METHOD=relay",
+ "EMAIL_FALLBACK_SENDMAIL=true",
+ } {
+ if !strings.Contains(result, want) {
+ t.Fatalf("missing %q in template: %q", want, result)
+ }
+ }
+}
+
+func TestPromptEmailDeliveryMethodAcceptsProxmoxAlias(t *testing.T) {
+ method, err := promptEmailDeliveryMethod(context.Background(), bufio.NewReader(strings.NewReader("proxmox-notifications\n")), "relay")
+ if err != nil {
+ t.Fatalf("promptEmailDeliveryMethod error: %v", err)
+ }
+ if method != "pmf" {
+ t.Fatalf("method=%q, want pmf", method)
+ }
+}
+
func TestConfigureEncryption(t *testing.T) {
var enabled bool
var err error
@@ -656,6 +689,34 @@ func TestRunConfigWizardCLIReturnsCronSchedule(t *testing.T) {
}
}
+func TestRunConfigWizardCLIEditExistingRemovesRuntimeDerivedKeys(t *testing.T) {
+ cfgFile := createTempFile(t, "BASE_DIR=/custom\nCRON_HOUR=2\nMARKER=1\n")
+ tmpConfigPath := cfgFile + ".tmp"
+ reader := bufio.NewReader(strings.NewReader("2\nn\nn\nn\nn\nn\nn\n03:15\n"))
+
+ var err error
+ captureStdout(t, func() {
+ _, err = runConfigWizardCLI(context.Background(), reader, cfgFile, tmpConfigPath, "/opt/proxsave", nil)
+ })
+ if err != nil {
+ t.Fatalf("runConfigWizardCLI returned error: %v", err)
+ }
+
+ content, readErr := os.ReadFile(cfgFile)
+ if readErr != nil {
+ t.Fatalf("expected config file to be written: %v", readErr)
+ }
+ values := parseWrittenEnvForTest(string(content))
+ for _, key := range []string{"BASE_DIR", "CRON_SCHEDULE", "CRON_HOUR", "CRON_MINUTE"} {
+ if _, ok := values[key]; ok {
+ t.Fatalf("expected %s to be removed from config:\n%s", key, content)
+ }
+ }
+ if values["MARKER"] != "1" {
+ t.Fatalf("expected existing MARKER to be preserved, got %q in:\n%s", values["MARKER"], content)
+ }
+}
+
func TestRunConfigWizardCLISkipLeavesCronScheduleEmpty(t *testing.T) {
cfgFile := createTempFile(t, "EXISTING=1\n")
tmpConfigPath := cfgFile + ".tmp"
@@ -714,3 +775,23 @@ func createTempFile(t *testing.T, content string) string {
_ = f.Close()
return f.Name()
}
+
+func parseWrittenEnvForTest(content string) map[string]string {
+ values := map[string]string{}
+ for _, line := range strings.Split(content, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ parts := strings.SplitN(trimmed, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(parts[0])
+ if fields := strings.Fields(key); len(fields) >= 2 && fields[0] == "export" {
+ key = fields[1]
+ }
+ values[strings.ToUpper(key)] = strings.TrimSpace(parts[1])
+ }
+ return values
+}
diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go
index bb9555d0..124afe95 100644
--- a/cmd/proxsave/install_tui.go
+++ b/cmd/proxsave/install_tui.go
@@ -23,8 +23,8 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo
}
configPath = resolvedPath
- // Derive BASE_DIR from the configuration path
- baseDir := deriveBaseDirFromConfig(configPath)
+ // Derive BASE_DIR from the installed executable path.
+ baseDir, _ := detectedBaseDirOrFallback()
_ = os.Setenv("BASE_DIR", baseDir)
// Before starting the TUI wizard, perform a best-effort cleanup of any existing
diff --git a/cmd/proxsave/main_config_modes.go b/cmd/proxsave/main_config_modes.go
index 3ee7383c..1b9c7dde 100644
--- a/cmd/proxsave/main_config_modes.go
+++ b/cmd/proxsave/main_config_modes.go
@@ -25,7 +25,8 @@ func runUpgradeConfigJSONMode(args *cli.Args) (int, bool) {
return types.ExitConfigError.Int(), true
}
- result, err := config.UpgradeConfigFile(args.ConfigPath)
+ baseDir, _ := detectedBaseDirOrFallback()
+ result, err := config.UpgradeConfigFileWithBaseDir(args.ConfigPath, baseDir)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Failed to upgrade configuration: %v\n", err)
return types.ExitConfigError.Int(), true
@@ -66,7 +67,8 @@ func runUpgradeConfigMode(_ context.Context, args *cli.Args, bootstrap *logging.
}
bootstrap.Printf("Upgrading configuration file: %s", args.ConfigPath)
- result, err := config.UpgradeConfigFile(args.ConfigPath)
+ baseDir, _ := detectedBaseDirOrFallback()
+ result, err := config.UpgradeConfigFileWithBaseDir(args.ConfigPath, baseDir)
if err != nil {
bootstrap.Error("ERROR: Failed to upgrade configuration: %v", err)
return types.ExitConfigError.Int(), true
diff --git a/cmd/proxsave/main_network.go b/cmd/proxsave/main_network.go
index c0f5af30..a05222fb 100644
--- a/cmd/proxsave/main_network.go
+++ b/cmd/proxsave/main_network.go
@@ -27,7 +27,7 @@ func featuresNeedNetwork(cfg *config.Config) (bool, []string) {
}
}
// Email via relay
- if cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay") {
+ if cfg.EmailEnabled && config.NormalizeEmailDeliveryMethod(cfg.EmailDeliveryMethod) == "relay" {
reasons = append(reasons, "Email relay delivery")
}
// Gotify
@@ -83,7 +83,7 @@ func cloudNetworkEnabled(cfg *config.Config) bool { return cfg.CloudEnabled }
func telegramNetworkEnabled(cfg *config.Config) bool { return cfg.TelegramEnabled }
func emailRelayNetworkEnabled(cfg *config.Config) bool {
- return cfg.EmailEnabled && strings.EqualFold(cfg.EmailDeliveryMethod, "relay")
+ return cfg.EmailEnabled && config.NormalizeEmailDeliveryMethod(cfg.EmailDeliveryMethod) == "relay"
}
func gotifyNetworkEnabled(cfg *config.Config) bool { return cfg.GotifyEnabled }
diff --git a/cmd/proxsave/main_runtime.go b/cmd/proxsave/main_runtime.go
index 96dc9b8b..434aa921 100644
--- a/cmd/proxsave/main_runtime.go
+++ b/cmd/proxsave/main_runtime.go
@@ -85,18 +85,10 @@ func bootstrapRuntime(ctx context.Context, args *cli.Args, bootstrap *logging.Bo
func loadRunConfig(args *cli.Args, bootstrap *logging.BootstrapLogger) (*config.Config, string, bool, int, bool) {
autoBaseDir, autoFound := detectBaseDir()
if autoBaseDir == "" {
- if _, err := os.Stat("/opt/proxsave"); err == nil {
- autoBaseDir = "/opt/proxsave"
- } else {
- autoBaseDir = "/opt/proxmox-backup"
- }
+ autoBaseDir = fallbackBaseDir()
}
initialEnvBaseDir := os.Getenv("BASE_DIR")
- if initialEnvBaseDir == "" {
- _ = os.Setenv("BASE_DIR", autoBaseDir)
- }
-
if err := ensureConfigExists(args.ConfigPath, bootstrap); err != nil {
bootstrap.Error("ERROR: %v", err)
return nil, "", false, types.ExitConfigError.Int(), false
@@ -104,14 +96,11 @@ func loadRunConfig(args *cli.Args, bootstrap *logging.BootstrapLogger) (*config.
bootstrap.Printf("Loading configuration from: %s", args.ConfigPath)
logging.DebugStepBootstrap(bootstrap, "main run", "loading configuration")
- cfg, err := config.LoadConfig(args.ConfigPath)
+ cfg, err := config.LoadConfigWithBaseDir(args.ConfigPath, autoBaseDir)
if err != nil {
bootstrap.Error("ERROR: Failed to load configuration: %v", err)
return nil, "", false, types.ExitConfigError.Int(), false
}
- if cfg.BaseDir == "" {
- cfg.BaseDir = autoBaseDir
- }
_ = os.Setenv("BASE_DIR", cfg.BaseDir)
bootstrap.Println("✓ Configuration loaded successfully")
return cfg, initialEnvBaseDir, autoFound, types.ExitSuccess.Int(), true
diff --git a/cmd/proxsave/main_runtime_log.go b/cmd/proxsave/main_runtime_log.go
index 2031a558..a0169189 100644
--- a/cmd/proxsave/main_runtime_log.go
+++ b/cmd/proxsave/main_runtime_log.go
@@ -1,11 +1,7 @@
// Package main contains the proxsave command entrypoint.
package main
-import (
- "strings"
-
- "github.com/tis24dev/proxsave/internal/logging"
-)
+import "github.com/tis24dev/proxsave/internal/logging"
func logRunContext(rt *appRuntime) {
logRunDryRunStatus(rt)
@@ -17,6 +13,7 @@ func logRunContext(rt *appRuntime) {
logging.Info("Compression: %s (level %d, mode %s)", rt.cfg.CompressionType, rt.cfg.CompressionLevel, rt.cfg.CompressionMode)
logging.Info("Base directory: %s (%s)", rt.cfg.BaseDir, baseDirSource)
logging.Info("Configuration file: %s (%s)", rt.args.ConfigPath, runConfigPathSource(rt))
+ logIgnoredBaseDirOverrides(rt)
}
func logRunDryRunStatus(rt *appRuntime) {
@@ -31,18 +28,24 @@ func logRunDryRunStatus(rt *appRuntime) {
}
func runBaseDirSource(rt *appRuntime) string {
- if rawBaseDir, ok := rt.cfg.Get("BASE_DIR"); ok && strings.TrimSpace(rawBaseDir) != "" {
- return "configured in backup.env"
- }
- if rt.initialEnvBaseDir != "" {
- return "from environment (BASE_DIR)"
- }
if rt.autoBaseDirFound {
return "auto-detected from executable path"
}
return "default fallback"
}
+func logIgnoredBaseDirOverrides(rt *appRuntime) {
+ if rt == nil || rt.cfg == nil {
+ return
+ }
+ if val, ok := rt.cfg.IgnoredBaseDirConfig(); ok {
+ logging.Warning("Ignoring deprecated BASE_DIR from backup.env (%q); using detected base directory %s", val, rt.cfg.BaseDir)
+ }
+ if val, ok := rt.cfg.IgnoredBaseDirEnv(); ok {
+ logging.Warning("Ignoring deprecated BASE_DIR from environment (%q); using detected base directory %s", val, rt.cfg.BaseDir)
+ }
+}
+
func runConfigPathSource(rt *appRuntime) string {
if rt.args.ConfigPathSource != "" {
return rt.args.ConfigPathSource
diff --git a/cmd/proxsave/new_install.go b/cmd/proxsave/new_install.go
index 357c21cd..9ee6fb35 100644
--- a/cmd/proxsave/new_install.go
+++ b/cmd/proxsave/new_install.go
@@ -29,9 +29,10 @@ func buildNewInstallPlan(configPath string) (newInstallPlan, error) {
buildSig = "n/a"
}
+ baseDir, _ := detectedBaseDirOrFallback()
return newInstallPlan{
ResolvedConfigPath: resolvedPath,
- BaseDir: deriveBaseDirFromConfig(resolvedPath),
+ BaseDir: baseDir,
BuildSignature: buildSig,
PreservedEntries: newInstallPreservedEntries(),
}, nil
diff --git a/cmd/proxsave/new_install_test.go b/cmd/proxsave/new_install_test.go
index 2daaa26f..8ced7df5 100644
--- a/cmd/proxsave/new_install_test.go
+++ b/cmd/proxsave/new_install_test.go
@@ -57,8 +57,9 @@ func TestNewInstallPreservedEntries(t *testing.T) {
}
func TestBuildNewInstallPlan(t *testing.T) {
- baseDir := t.TempDir()
- configPath := filepath.Join(baseDir, "env", "backup.env")
+ configRoot := t.TempDir()
+ configPath := filepath.Join(configRoot, "env", "backup.env")
+ wantBaseDir, _ := detectedBaseDirOrFallback()
plan, err := buildNewInstallPlan(configPath)
if err != nil {
@@ -67,8 +68,8 @@ func TestBuildNewInstallPlan(t *testing.T) {
if plan.ResolvedConfigPath != configPath {
t.Fatalf("resolved config path = %q, want %q", plan.ResolvedConfigPath, configPath)
}
- if plan.BaseDir != baseDir {
- t.Fatalf("base dir = %q, want %q", plan.BaseDir, baseDir)
+ if plan.BaseDir != wantBaseDir {
+ t.Fatalf("base dir = %q, want %q", plan.BaseDir, wantBaseDir)
}
if strings.TrimSpace(plan.BuildSignature) == "" {
t.Fatalf("build signature should not be empty")
@@ -82,6 +83,7 @@ func TestBuildNewInstallPlanUsesNAWhenBuildSignatureBlank(t *testing.T) {
registerNewInstallBuildSignature(t, func() string { return " " })
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
plan, err := buildNewInstallPlan(configPath)
@@ -215,6 +217,7 @@ func TestRunNewInstallCLIUsesCLIConfirmOnly(t *testing.T) {
}()
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
stalePath := filepath.Join(baseDir, "stale.txt")
if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil {
@@ -277,6 +280,7 @@ func TestRunNewInstallCancelSkipsReset(t *testing.T) {
}()
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
markerPath := filepath.Join(baseDir, "marker.txt")
if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil {
@@ -320,6 +324,7 @@ func TestRunNewInstallTUIPassesContextToConfirm(t *testing.T) {
}()
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
ctx := t.Context()
@@ -367,6 +372,7 @@ func TestRunNewInstallTUIUsesTUIConfirmAndRunInstallTUI(t *testing.T) {
}()
baseDir := t.TempDir()
+ forceDetectedBaseDirForTest(t, baseDir)
configPath := filepath.Join(baseDir, "env", "backup.env")
stalePath := filepath.Join(baseDir, "stale.txt")
if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil {
diff --git a/cmd/proxsave/newkey.go b/cmd/proxsave/newkey.go
index 5adbf853..be80f3f4 100644
--- a/cmd/proxsave/newkey.go
+++ b/cmd/proxsave/newkey.go
@@ -52,15 +52,7 @@ func runNewKey(ctx context.Context, configPath string, logLevel types.LogLevel,
}
configPath = resolvedPath
- // Derive BASE_DIR from the configuration path
- baseDir := filepath.Dir(filepath.Dir(configPath))
- if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) {
- if _, err := os.Stat("/opt/proxsave"); err == nil {
- baseDir = "/opt/proxsave"
- } else {
- baseDir = "/opt/proxmox-backup"
- }
- }
+ baseDir, _ := detectedBaseDirOrFallback()
_ = os.Setenv("BASE_DIR", baseDir)
logging.DebugStepBootstrap(bootstrap, "newkey workflow", "config=%s base=%s", configPath, baseDir)
@@ -133,7 +125,7 @@ func loadNewKeyConfig(configPath, baseDir string) (*config.Config, string, error
}
if _, err := os.Stat(configPath); err == nil {
- loaded, err := config.LoadConfig(configPath)
+ loaded, err := config.LoadConfigWithBaseDir(configPath, baseDir)
if err != nil {
return nil, "", fmt.Errorf("load configuration for newkey: %w", err)
}
diff --git a/cmd/proxsave/permissions.go b/cmd/proxsave/permissions.go
index 4631d0d7..b063f601 100644
--- a/cmd/proxsave/permissions.go
+++ b/cmd/proxsave/permissions.go
@@ -163,7 +163,10 @@ func fixPermissionsAfterInstall(ctx context.Context, configPath, baseDir string,
return "skipped", "permissions normalization skipped (configuration path unavailable)"
}
- cfg, err := config.LoadConfig(configPath)
+ if baseDir == "" {
+ baseDir, _ = detectedBaseDirOrFallback()
+ }
+ cfg, err := config.LoadConfigWithBaseDir(configPath, baseDir)
if err != nil {
if bootstrap != nil {
bootstrap.Warning("Post-install: skipping permission fix, failed to load configuration: %v", err)
@@ -171,10 +174,6 @@ func fixPermissionsAfterInstall(ctx context.Context, configPath, baseDir string,
return "error", "unable to normalize permissions: failed to load configuration (see log)"
}
- if strings.TrimSpace(cfg.BaseDir) == "" && baseDir != "" {
- cfg.BaseDir = baseDir
- }
-
logger := logging.New(types.LogLevelInfo, cfg.UseColor)
// Force-enable security checks in a safe, non-blocking way for install.
diff --git a/cmd/proxsave/runtime_helpers.go b/cmd/proxsave/runtime_helpers.go
index 2de51d52..9c04fa55 100644
--- a/cmd/proxsave/runtime_helpers.go
+++ b/cmd/proxsave/runtime_helpers.go
@@ -80,18 +80,65 @@ func detectExecInfo() ExecInfo {
}
execDir := filepath.Dir(execPath)
- dir := execDir
- originalDir := dir
- baseDir := ""
+ baseDir, hasBase := resolveBaseDirFromExecutablePath(execPath)
+ return ExecInfo{
+ ExecPath: execPath,
+ ExecDir: execDir,
+ BaseDir: baseDir,
+ HasBase: hasBase,
+ }
+}
+
+func detectBaseDir() (string, bool) {
+ info := getExecInfo()
+ return info.BaseDir, info.HasBase
+}
+
+func detectedBaseDirOrFallback() (string, bool) {
+ baseDir, found := detectBaseDir()
+ if strings.TrimSpace(baseDir) == "" {
+ return fallbackBaseDir(), false
+ }
+ return baseDir, found
+}
+
+func resolveBaseDirFromExecutablePath(execPath string) (string, bool) {
+ clean := filepath.Clean(strings.TrimSpace(execPath))
+ if clean == "" || clean == "." {
+ return fallbackBaseDir(), false
+ }
+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && strings.TrimSpace(resolved) != "" {
+ clean = filepath.Clean(resolved)
+ }
+ if baseDir, ok := baseDirFromStandardExecutableLayout(clean); ok {
+ return baseDir, true
+ }
+ if baseDir, ok := baseDirFromInstallMarkers(filepath.Dir(clean)); ok {
+ return baseDir, true
+ }
+ return fallbackBaseDir(), false
+}
+
+func baseDirFromStandardExecutableLayout(execPath string) (string, bool) {
+ dir := filepath.Dir(execPath)
+ if filepath.Base(dir) != "build" {
+ return "", false
+ }
+ parent := filepath.Dir(dir)
+ if parent == "" || parent == "." || parent == string(filepath.Separator) {
+ return "", false
+ }
+ return parent, true
+}
+
+func baseDirFromInstallMarkers(startDir string) (string, bool) {
+ dir := filepath.Clean(strings.TrimSpace(startDir))
for dir != "" && dir != "." {
- if info, err := os.Stat(filepath.Join(dir, "env")); err == nil && info.IsDir() {
- baseDir = dir
- break
- }
- if info, err := os.Stat(filepath.Join(dir, "script")); err == nil && info.IsDir() {
- baseDir = dir
- break
+ for _, marker := range []string{"configs", "env", "script", "identity"} {
+ if info, err := os.Stat(filepath.Join(dir, marker)); err == nil && info.IsDir() {
+ return dir, true
+ }
}
parent := filepath.Dir(dir)
if parent == dir {
@@ -99,24 +146,17 @@ func detectExecInfo() ExecInfo {
}
dir = parent
}
+ return "", false
+}
- if baseDir == "" {
- if parent := filepath.Dir(originalDir); parent != "" && parent != "." && parent != string(filepath.Separator) {
- baseDir = parent
- }
+func fallbackBaseDir() string {
+ if info, err := os.Stat("/opt/proxsave"); err == nil && info.IsDir() {
+ return "/opt/proxsave"
}
-
- return ExecInfo{
- ExecPath: execPath,
- ExecDir: execDir,
- BaseDir: baseDir,
- HasBase: baseDir != "",
+ if info, err := os.Stat("/opt/proxmox-backup"); err == nil && info.IsDir() {
+ return "/opt/proxmox-backup"
}
-}
-
-func detectBaseDir() (string, bool) {
- info := getExecInfo()
- return info.BaseDir, info.HasBase
+ return "/opt/proxsave"
}
func collectExecPathCandidates() []string {
diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go
index dbfcefb3..77ac5c20 100644
--- a/cmd/proxsave/upgrade.go
+++ b/cmd/proxsave/upgrade.go
@@ -44,10 +44,7 @@ type releaseInfo struct {
// - upgrades backup.env by adding missing keys from the new template (preserving existing values)
// - refreshes symlinks/cron/docs and normalizes permissions/ownership
func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.BootstrapLogger) int {
- baseDir := filepath.Dir(filepath.Dir(args.ConfigPath))
- if baseDir == "" || baseDir == "." || baseDir == string(filepath.Separator) {
- baseDir = "/opt/proxsave"
- }
+ baseDir, _ := detectedBaseDirOrFallback()
_ = os.Setenv("BASE_DIR", baseDir)
logLevel := types.LogLevelInfo
@@ -92,15 +89,12 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
bootstrap.Printf("Base directory: %s", baseDir)
bootstrap.Println("")
- cfg, err := config.LoadConfig(args.ConfigPath)
+ _, err := config.LoadConfigWithBaseDir(args.ConfigPath, baseDir)
if err != nil {
bootstrap.Error("ERROR: Failed to load configuration: %v", err)
workflowErr = err
return types.ExitConfigError.Int()
}
- if strings.TrimSpace(cfg.BaseDir) == "" {
- cfg.BaseDir = baseDir
- }
// Discover the latest available release on GitHub and compare with the
// currently installed version before proceeding.
diff --git a/docs/BACKUP_ENV_MAPPING.md b/docs/BACKUP_ENV_MAPPING.md
index 24d5eb81..25f9895c 100644
--- a/docs/BACKUP_ENV_MAPPING.md
+++ b/docs/BACKUP_ENV_MAPPING.md
@@ -51,9 +51,10 @@ COROSYNC_CONFIG_PATH = SAME
CUSTOM_BACKUP_PATHS = SAME
DEBUG_LEVEL = SAME
DISABLE_NETWORK_PREFLIGHT = SAME
-EMAIL_DELIVERY_METHOD = SAME (now supports `relay`, `sendmail` (/usr/sbin/sendmail), and `pmf` (proxmox-mail-forward / Proxmox Notifications))
+EMAIL_DELIVERY_METHOD = SAME (supports `relay`, `sendmail` (/usr/sbin/sendmail), and `pmf` (proxmox-mail-forward / Proxmox Notifications); aliases like `proxmox-notifications` normalize to `pmf`)
EMAIL_ENABLED = SAME
-EMAIL_FALLBACK_SENDMAIL = SAME (historical name; when EMAIL_DELIVERY_METHOD=relay, enables fallback to `pmf`)
+EMAIL_FALLBACK_SENDMAIL = SAME (local sendmail failover for relay; for pmf the fallback chain is relay then sendmail)
+EMAIL_FALLBACK_PMF = TRANSITIONAL alias accepted for compatibility with older templates; prefer `EMAIL_FALLBACK_SENDMAIL`
EMAIL_FROM = SAME
EMAIL_RECIPIENT = SAME
ENABLE_DEDUPLICATION = SAME
diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md
index bfd14304..611cea0a 100644
--- a/docs/CLI_REFERENCE.md
+++ b/docs/CLI_REFERENCE.md
@@ -143,7 +143,7 @@ Some interactive commands support two interface modes:
2. Optionally configures secondary storage (`SECONDARY_PATH` required if enabled; `SECONDARY_LOG_PATH` optional; invalid secondary paths are re-prompted/rejected; disabling secondary storage clears both saved secondary paths)
3. Optionally configures cloud storage (rclone)
4. Optionally enables firewall rules collection (`BACKUP_FIREWALL_RULES=false` by default)
-5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`)
+5. Optionally sets up notifications (Telegram, Email; Email asks for a delivery method and defaults to `EMAIL_DELIVERY_METHOD=relay` with `EMAIL_FALLBACK_SENDMAIL=true`)
6. Optionally configures encryption (AGE setup)
7. Optionally selects a cron time (HH:MM, default `02:00`) for the `proxsave` cron entry in both CLI and TUI install flows
8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (actionable hints like `set BACKUP_*=false to disable`)
@@ -830,6 +830,9 @@ CONFIG_FILE=/etc/pbs/prod.env ./build/proxsave
# Force dry-run mode
DRY_RUN=true ./build/proxsave
+# BASE_DIR is not an override; it is detected from the installed executable.
+# BASE_DIR in the environment or backup.env is deprecated and ignored.
+
# PBS restore behavior
# Selected interactively during `--restore` on PBS hosts (Merge vs Clean 1:1).
@@ -840,7 +843,7 @@ DEBUG_LEVEL=extreme ./build/proxsave --log-level debug
USE_COLOR=false ./build/proxsave
```
-**Priority**: Environment variables > Configuration file > Defaults
+**Priority**: Environment variables > Configuration file > Defaults, except `BASE_DIR`, which is always runtime-detected.
---
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 37d4df43..12e63a1a 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -209,12 +209,13 @@ When `SET_BACKUP_PERMISSIONS=true`, the system applies Bash-compatible ownership
- Non-fatal: All failures logged as warnings
- Backup continues even if permission changes fail
- User/group not found: logs warning and skips operation
+- Backup/log paths on non-POSIX filesystems such as CIFS/SMB, NTFS, FAT/exFAT, FUSE, or network filesystems without Unix ownership support are detected and skipped for `chown`/`chmod` and security permission warnings. Windows-backed CIFS shares are expected to manage permissions on the Windows side.
**Use cases**:
- Migration from legacy Bash version
- Multi-user environments requiring specific ownership
- Shared backup storage with group access
-- NFS/CIFS mounts requiring specific ownership
+- POSIX-capable NFS mounts requiring specific ownership
**Example**:
```bash
@@ -249,8 +250,9 @@ MIN_DISK_SPACE_CLOUD_GB=1 # Cloud storage (not enforced for remote)
```bash
# Base directory for all operations (auto-detected at runtime)
-# BASE_DIR is derived from the executable/config location, so it is usually not
-# written in backup.env.
+# BASE_DIR is auto-detected from the installed proxsave executable; do not set it
+# in backup.env. Active BASE_DIR=... lines are deprecated and ignored.
+# BASE_DIR=/opt/proxsave
# Lock file directory
LOCK_PATH=${BASE_DIR}/lock
@@ -265,7 +267,7 @@ BACKUP_PATH=${BASE_DIR}/backup
LOG_PATH=${BASE_DIR}/log
```
-**Path resolution**: `${BASE_DIR}` expands automatically. Scalar string values also support `$VAR` / `${VAR}` expansion (config keys first, then environment variables).
+**Path resolution**: `${BASE_DIR}` expands automatically from the installed `proxsave` executable path. Scalar string values also support `$VAR` / `${VAR}` expansion (config keys first, then environment variables), but `BASE_DIR` itself is not configurable from `backup.env` or the parent environment.
---
@@ -841,7 +843,7 @@ EMAIL_ENABLED=false # true | false
# Delivery method
EMAIL_DELIVERY_METHOD=relay # relay | sendmail | pmf
-# Fallback to pmf (proxmox-mail-forward) if relay fails
+# Fallback to local sendmail if the primary path cannot deliver
EMAIL_FALLBACK_SENDMAIL=true # true | false
# Recipient
@@ -855,16 +857,22 @@ EMAIL_FROM=no-reply@proxmox.tis24.it
If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EMAIL_ENABLE` is still accepted during migration and runtime loading.
-**Delivery methods**:
-- **relay**: Uses cloud relay (outbound HTTPS)
-- **sendmail**: Uses `/usr/sbin/sendmail` (requires a working local MTA, e.g. postfix)
-- **pmf**: Uses Proxmox Notifications via `proxmox-mail-forward`
+**Which delivery method should I choose?**
+
+| Method | Best when | Where SMTP is configured |
+| --- | --- | --- |
+| `relay` | Default for new installs. Uses the built-in TIS24 cloud relay over outbound HTTPS. | In the relay service; ProxSave only needs a recipient. |
+| `sendmail` | The node already has a local MTA such as Postfix, Exim, or Sendmail. | In the local MTA. ProxSave calls `/usr/sbin/sendmail`. |
+| `pmf` | You explicitly want Proxmox Notifications via `proxmox-mail-forward`. | In Proxmox (`Datacenter -> Notifications` on PVE, or the PBS notification UI/config). ProxSave does not ask for SMTP host/port/user/password. |
+
+`pmf` may also be written as `proxmox`, `proxmox-notifications`, or `proxmox-mail-forward`; ProxSave normalizes those aliases to `pmf`.
**Notes**:
-- Allowed values for `EMAIL_DELIVERY_METHOD` are: `relay`, `sendmail`, `pmf` (invalid values will skip Email with a warning).
-- `EMAIL_FALLBACK_SENDMAIL` is a historical name (kept for compatibility). When `EMAIL_DELIVERY_METHOD=relay`, it enables fallback to **pmf** (it will not fall back to `/usr/sbin/sendmail`).
+- Allowed values for `EMAIL_DELIVERY_METHOD` are: `pmf`, `relay`, `sendmail` (invalid values will skip Email with a warning).
+- `EMAIL_FALLBACK_SENDMAIL=true` controls local `/usr/sbin/sendmail` failover. `EMAIL_FALLBACK_PMF` is accepted only as a transitional alias from older templates.
- `relay` requires a real mailbox recipient and blocks `root@…` recipients; set `EMAIL_RECIPIENT` to a non-root mailbox if needed.
-- When relay preconditions fail before delivery starts (for example missing recipient, autodetect failure, or blocked `root@…` recipient) and fallback is enabled, ProxSave may bypass relay and invoke `pmf` directly.
+- Default install behavior is `relay -> sendmail`.
+- If you manually set `EMAIL_DELIVERY_METHOD=pmf`, fallback order is `pmf -> relay -> sendmail` when `EMAIL_FALLBACK_SENDMAIL=true`.
- When logs say the relay "accepted request", it means the worker and upstream email API accepted the submission. It does **not** guarantee final inbox delivery (the message may still bounce, be deferred, or land in spam later).
- If `EMAIL_RECIPIENT` is empty, ProxSave auto-detects the recipient from the `root@pam` user:
- **PVE**: Proxmox API via `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg`
diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md
index 61b2bdae..8a8e73ab 100644
--- a/docs/EXAMPLES.md
+++ b/docs/EXAMPLES.md
@@ -569,21 +569,23 @@ WEBHOOK_PUSHOVER_PRIORITY=0
#### 2. Email Configuration
```bash
-# Option A: Cloud relay (outbound HTTPS)
+# Option A: Cloud relay (default, outbound HTTPS)
# - Set EMAIL_DELIVERY_METHOD=relay and configure EMAIL_RECIPIENT (or leave empty for root@pam auto-detect)
# - Relay blocks root@… recipients; use a real non-root mailbox for EMAIL_RECIPIENT
# - No local SMTP/MTA setup required on the node
-# - Optional: set EMAIL_FALLBACK_SENDMAIL=true to fall back to EMAIL_DELIVERY_METHOD=pmf when the relay fails
+# - Optional/default: set EMAIL_FALLBACK_SENDMAIL=true to fall back to local sendmail when the relay fails
# Option B: Local sendmail (/usr/sbin/sendmail)
# - Set EMAIL_DELIVERY_METHOD=sendmail
# - Requires a working local MTA (e.g. postfix) on the node
# - EMAIL_RECIPIENT is required (or auto-detected from Proxmox root@pam if configured)
-# Option C: Proxmox Notifications via proxmox-mail-forward
+# Option C: Proxmox Notifications (manual)
# - Set EMAIL_DELIVERY_METHOD=pmf
-# - Ensure Proxmox Notifications targets/matchers are configured
+# - Configure SMTP/Sendmail targets and matchers in Proxmox Notifications
+# - ProxSave does not need SMTP host/port/user/password
# - EMAIL_RECIPIENT is optional (only used for the To: header)
+# - With EMAIL_FALLBACK_SENDMAIL=true, fallback order is pmf -> relay -> sendmail
# - Optional quick check (runs the forwarder directly; run as root):
printf "To: root\nSubject: proxsave test\n\nHello from proxsave\n" | sudo /usr/libexec/proxmox-mail-forward
```
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 86a0ff10..3a4ebb77 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -229,7 +229,7 @@ Final install steps still run:
2. **Secondary storage**: Optional path for backup/log copies; disabling it clears both saved secondary paths from `backup.env`
3. **Cloud storage (rclone)**: Optional rclone configuration (supports `CLOUD_REMOTE` as a remote name (recommended) or legacy `remote:path`; `CLOUD_LOG_PATH` supports path-only (recommended) or `otherremote:/path`)
4. **Firewall rules**: Optional firewall rules collection toggle (`BACKUP_FIREWALL_RULES=false` by default; supports iptables/nftables)
-5. **Notifications**: Enable Telegram (centralized) and Email notifications (wizard defaults to `EMAIL_DELIVERY_METHOD=relay`; you can switch to `sendmail` or `pmf` later)
+5. **Notifications**: Enable Telegram (centralized) and Email notifications; Email asks for a delivery method and defaults to `relay` with `sendmail` failover. Use `pmf` only when you want Proxmox Notifications via `proxmox-mail-forward`.
6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled)
7. **Cron schedule**: Choose cron time (HH:MM, default `02:00`) for the `proxsave` cron entry in both CLI and TUI install modes
8. **Post-install check (optional)**: Runs `proxsave --dry-run` and shows actionable warnings like `set BACKUP_*=false to disable`, allowing you to disable unused collectors and reduce WARNING noise
@@ -282,6 +282,8 @@ When shown, it does **not** modify your `backup.env`. It only:
After completion, edit `configs/backup.env` manually for advanced options.
+`BASE_DIR` is detected from the installed `proxsave` executable. Do not add an active `BASE_DIR=...` line to `backup.env`; upgrades remove it and runtime ignores it if present.
+
---
## Upgrading from Previous Bash Version (v0.7.4-bash or Earlier)
diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md
index 0c58a3da..0858470b 100644
--- a/docs/MIGRATION_GUIDE.md
+++ b/docs/MIGRATION_GUIDE.md
@@ -321,6 +321,7 @@ These Bash variables are **not needed** in Go (skip them during migration):
**Paths**:
- `BACKUP_ENV_PATH` → Go uses fixed `configs/backup.env`
- `SCRIPT_DIR` → Go binary is self-contained
+- `BASE_DIR` → Go auto-detects it from the installed `proxsave` executable; active migrated values are ignored/removed
**Internal Logic**:
- `BACKUP_TIMESTAMP_FORMAT` → Go uses ISO 8601 internally
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 30e789c0..ca424402 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -147,6 +147,9 @@ AUTO_FIX_PERMISSIONS=true
└── proxsave 755 (-rwxr-xr-x)
```
+**CIFS/SMB or Windows-backed shares**:
+Linux permission modes and `root:root` ownership are often synthetic on CIFS/SMB mounts, especially when the server is Windows. ProxSave detects non-POSIX backup/log filesystems and skips POSIX permission/ownership warnings for `BACKUP_PATH`, `LOG_PATH`, `SECONDARY_PATH`, and `SECONDARY_LOG_PATH`. If warnings persist, confirm the share is mounted before ProxSave starts and that the mount type appears as `cifs`/`smb` in `/proc/mounts`.
+
---
#### Error: `Invalid configuration value for COMPRESSION_TYPE`
@@ -557,7 +560,23 @@ EMAIL_DELIVERY_METHOD=sendmail # /usr/sbin/sendmail (local MTA required)
EMAIL_DELIVERY_METHOD=pmf # Proxmox Notifications via proxmox-mail-forward
```
-If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVERY_METHOD` is exactly one of: `relay`, `sendmail`, `pmf` (typos will skip Email with a warning like: `Email: enabled but not initialized (...)`).
+If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVERY_METHOD` is one of: `pmf`, `relay`, `sendmail` (aliases such as `proxmox-notifications` normalize to `pmf`; typos will skip Email with a warning like: `Email: enabled but not initialized (...)`).
+
+##### If `EMAIL_DELIVERY_METHOD=pmf`
+
+This mode uses Proxmox Notifications via `proxmox-mail-forward`. It is the recommended mode on Proxmox hosts when you expected SMTP settings in ProxSave: configure SMTP targets/matchers in Proxmox, then let ProxSave hand the message to Proxmox.
+
+- `EMAIL_RECIPIENT` is optional in this mode and is only used for the `To:` header.
+- If PMF fails and `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave tries the relay first and then local sendmail.
+- Verify `proxmox-mail-forward` exists:
+ ```bash
+ test -x /usr/libexec/proxmox-mail-forward && echo "proxmox-mail-forward OK" || echo "proxmox-mail-forward not found"
+ ```
+- Verify Proxmox Notifications configuration in the UI (`Datacenter -> Notifications` on PVE, or the PBS notification UI/config).
+- Direct handoff test:
+ ```bash
+ printf "To: root\nSubject: proxsave test\n\nHello from proxsave\n" | sudo /usr/libexec/proxmox-mail-forward
+ ```
##### If `EMAIL_DELIVERY_METHOD=relay`
@@ -570,7 +589,7 @@ If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVER
- **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg`
- **Dual**: intentionally reuses the **PVE** path for `root@pam` email discovery
- Relay blocks `root@…` recipients; use a real non-root mailbox for `EMAIL_RECIPIENT`.
-- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails. If relay cannot even start because recipient resolution/preconditions fail, ProxSave can bypass relay and invoke the PMF fallback directly.
+- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to local `/usr/sbin/sendmail` when relay delivery fails. If relay cannot start because no recipient is available, sendmail cannot help either; configure `EMAIL_RECIPIENT` or the `root@pam` email in Proxmox.
- Check the proxsave logs for `email-relay` warnings/errors.
- `Email relay accepted request ...` means the relay accepted the submission. It does **not** guarantee final inbox delivery; later provider-side failures/bounces are outside the ProxSave process.
@@ -624,17 +643,6 @@ This mode uses `/usr/sbin/sendmail`, so your node must have a working local MTA
```
- Check your MTA status and queue (`systemctl status postfix`, `mailq`, `/var/log/mail.log`).
-##### If `EMAIL_DELIVERY_METHOD=pmf`
-
-This mode uses Proxmox Notifications via `proxmox-mail-forward` (final recipients are configured in Proxmox, not in proxsave).
-
-- `EMAIL_RECIPIENT` is optional in this mode and is only used for the `To:` header.
-- Verify `proxmox-mail-forward` exists:
- ```bash
- test -x /usr/libexec/proxmox-mail-forward && echo "proxmox-mail-forward OK" || echo "proxmox-mail-forward not found"
- ```
-- Verify Proxmox Notifications configuration in the UI (`Datacenter -> Notifications`).
-
---
#### Error: `Backup path full` warnings but backup succeeds
diff --git a/internal/backup/archiver.go b/internal/backup/archiver.go
index 5a99c3ef..8f9a7691 100644
--- a/internal/backup/archiver.go
+++ b/internal/backup/archiver.go
@@ -611,11 +611,9 @@ func (a *Archiver) createXZArchive(ctx context.Context, sourceDir, outputPath st
}()
if err := cmd.Start(); err != nil {
- _ = pw.Close()
- if startErr := <-errChan; startErr != nil {
- return startErr
- }
- return fmt.Errorf("failed to start xz: %w", err)
+ startErr := fmt.Errorf("failed to start xz: %w", err)
+ drainTarWriterAfterCompressorStartFailure(pw, errChan, startErr)
+ return startErr
}
tarErr := <-errChan
@@ -684,11 +682,9 @@ func (a *Archiver) createZstdArchive(ctx context.Context, sourceDir, outputPath
}()
if err := cmd.Start(); err != nil {
- _ = pw.Close()
- if startErr := <-errChan; startErr != nil {
- return startErr
- }
- return fmt.Errorf("failed to start zstd: %w", err)
+ startErr := fmt.Errorf("failed to start zstd: %w", err)
+ drainTarWriterAfterCompressorStartFailure(pw, errChan, startErr)
+ return startErr
}
tarErr := <-errChan
@@ -728,6 +724,11 @@ func (a *Archiver) attachStderrLogger(cmd *exec.Cmd, algo string) error {
return nil
}
+func drainTarWriterAfterCompressorStartFailure(pw *io.PipeWriter, errChan <-chan error, startErr error) {
+ _ = pw.CloseWithError(startErr)
+ _ = <-errChan
+}
+
func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputPath string, cmd *exec.Cmd, algo string) (err error) {
outFile, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
@@ -767,11 +768,9 @@ func (a *Archiver) pipeTarThroughCommand(ctx context.Context, sourceDir, outputP
}()
if err := cmd.Start(); err != nil {
- _ = pw.Close()
- if startErr := <-errChan; startErr != nil {
- return startErr
- }
- return fmt.Errorf("failed to start %s: %w", algo, err)
+ startErr := fmt.Errorf("failed to start %s: %w", algo, err)
+ drainTarWriterAfterCompressorStartFailure(pw, errChan, startErr)
+ return startErr
}
if tarErr := <-errChan; tarErr != nil {
diff --git a/internal/backup/archiver_test.go b/internal/backup/archiver_test.go
index fb09de31..13febf5e 100644
--- a/internal/backup/archiver_test.go
+++ b/internal/backup/archiver_test.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
+ "os/exec"
"path/filepath"
"reflect"
"strings"
@@ -532,6 +533,74 @@ func TestCompressionErrorWrap(t *testing.T) {
}
}
+func TestCompressorStartFailureReturnsStartError(t *testing.T) {
+ tmp := t.TempDir()
+ sourceDir := filepath.Join(tmp, "source")
+ if err := os.MkdirAll(sourceDir, 0o755); err != nil {
+ t.Fatalf("mkdir source: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(sourceDir, "file.txt"), []byte("content"), 0o644); err != nil {
+ t.Fatalf("write source file: %v", err)
+ }
+
+ logger := logging.New(types.LogLevelError, false)
+ missingCommand := filepath.Join(tmp, "missing-compressor")
+ newArchiver := func() *Archiver {
+ a := NewArchiver(logger, &ArchiverConfig{
+ Compression: types.CompressionXZ,
+ CompressionLevel: 3,
+ })
+ a.deps.CommandContext = func(ctx context.Context, _ string, args ...string) (*exec.Cmd, error) {
+ return exec.CommandContext(ctx, missingCommand, args...), nil
+ }
+ return a
+ }
+
+ tests := []struct {
+ name string
+ run func(context.Context, *Archiver, string, string) error
+ want string
+ }{
+ {
+ name: "xz",
+ run: func(ctx context.Context, a *Archiver, source, output string) error {
+ return a.createXZArchive(ctx, source, output)
+ },
+ want: "failed to start xz",
+ },
+ {
+ name: "zstd",
+ run: func(ctx context.Context, a *Archiver, source, output string) error {
+ return a.createZstdArchive(ctx, source, output)
+ },
+ want: "failed to start zstd",
+ },
+ {
+ name: "generic",
+ run: func(ctx context.Context, a *Archiver, source, output string) error {
+ cmd := exec.CommandContext(ctx, missingCommand, "-c")
+ return a.pipeTarThroughCommand(ctx, source, output, cmd, "fake")
+ },
+ want: "failed to start fake",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.run(context.Background(), newArchiver(), sourceDir, filepath.Join(tmp, tt.name+".tar"))
+ if err == nil {
+ t.Fatalf("expected start failure")
+ }
+ if !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("error=%v, want to contain %q", err, tt.want)
+ }
+ if errors.Is(err, io.ErrClosedPipe) {
+ t.Fatalf("returned pipe error instead of compressor start error: %v", err)
+ }
+ })
+ }
+}
+
func TestArchiverCompressionGetters(t *testing.T) {
logger := logging.New(types.LogLevelInfo, false)
cfg := &ArchiverConfig{
diff --git a/internal/backup/collector_pbs_datastore.go b/internal/backup/collector_pbs_datastore.go
index 858c0281..6e92e43e 100644
--- a/internal/backup/collector_pbs_datastore.go
+++ b/internal/backup/collector_pbs_datastore.go
@@ -550,6 +550,9 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn
dsWorkers = 1
}
+ childCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
var (
wg sync.WaitGroup
sem = make(chan struct{}, dsWorkers)
@@ -564,18 +567,22 @@ func (c *Collector) runPBSPXARStep(ctx context.Context, state *pbsPxarState, fn
defer wg.Done()
select {
case sem <- struct{}{}:
- case <-ctx.Done():
+ case <-childCtx.Done():
return
}
defer func() { <-sem }()
+ if err := childCtx.Err(); err != nil {
+ return
+ }
- if err := fn(ctx, ds, state); err != nil {
+ if err := fn(childCtx, ds, state); err != nil {
if errors.Is(err, context.Canceled) {
return
}
errMu.Lock()
if firstErr == nil {
firstErr = err
+ cancel()
}
errMu.Unlock()
}
diff --git a/internal/backup/collector_pxar_datastore_test.go b/internal/backup/collector_pxar_datastore_test.go
index 5d9b4d19..cb2f8fad 100644
--- a/internal/backup/collector_pxar_datastore_test.go
+++ b/internal/backup/collector_pxar_datastore_test.go
@@ -2,9 +2,11 @@ package backup
import (
"context"
+ "errors"
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"github.com/tis24dev/proxsave/internal/types"
@@ -52,3 +54,30 @@ func TestWritePxarListReportWithFiles(t *testing.T) {
t.Fatalf("expected pxar file listed, got %s", string(content))
}
}
+
+func TestRunPBSPXARStepCancelsPendingWorkersOnFirstError(t *testing.T) {
+ cfg := GetDefaultCollectorConfig()
+ cfg.PxarDatastoreConcurrency = 1
+ c := NewCollector(newTestLogger(), cfg, t.TempDir(), types.ProxmoxBS, false)
+ state := &pbsPxarState{
+ eligible: []pbsDatastore{
+ {Name: "ds1", Path: "/tmp/ds1"},
+ {Name: "ds2", Path: "/tmp/ds2"},
+ {Name: "ds3", Path: "/tmp/ds3"},
+ },
+ }
+
+ errBoom := errors.New("pxar failed")
+ var calls int32
+ err := c.runPBSPXARStep(context.Background(), state, func(ctx context.Context, _ pbsDatastore, _ *pbsPxarState) error {
+ atomic.AddInt32(&calls, 1)
+ return errBoom
+ })
+
+ if !errors.Is(err, errBoom) {
+ t.Fatalf("runPBSPXARStep error = %v, want %v", err, errBoom)
+ }
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("fn called %d times, want 1", got)
+ }
+}
diff --git a/internal/checks/checks.go b/internal/checks/checks.go
index f9caaa86..6cbe30ff 100644
--- a/internal/checks/checks.go
+++ b/internal/checks/checks.go
@@ -20,6 +20,7 @@ import (
// checks to allow tests to inject controlled failures (e.g., EIO) without
// depending on specific filesystem behavior.
var createTestFile = os.Create
+var closeTestFile = func(f *os.File) error { return f.Close() }
var (
osStat = os.Stat
@@ -391,7 +392,6 @@ func (c *Checker) CheckLockFile() CheckResult {
c.logger.Warning("Failed to sync lock file %s: %v", lockPath, err)
}
if err := f.Close(); err != nil {
- c.logger.Warning("Failed to close lock file %s: %v", lockPath, err)
c.removePartialLockFile(lockPath)
result.Error = fmt.Errorf("failed to close lock file: %w", err)
result.Message = result.Error.Error()
@@ -441,8 +441,14 @@ func (c *Checker) CheckPermissions() CheckResult {
for attempt := 1; attempt <= maxAttempts; attempt++ {
f, err := createTestFile(testFile)
if err == nil {
- if closeErr := f.Close(); closeErr != nil {
+ if closeErr := closeTestFile(f); closeErr != nil {
lastErr = closeErr
+ if errors.Is(closeErr, syscall.EIO) && attempt < maxAttempts {
+ c.logger.Warning("I/O error while closing permission test file in %s (attempt %d/%d), will retry: %v",
+ dir, attempt, maxAttempts, closeErr)
+ time.Sleep(retryDelay)
+ continue
+ }
} else {
lastErr = nil
}
@@ -637,6 +643,7 @@ func (c *Checker) CheckTempDirectory() CheckResult {
result.Message = result.Error.Error()
return result
}
+ defer func() { _ = osRemove(testSymlink) }()
if err := osRemove(testSymlink); err != nil && !os.IsNotExist(err) {
c.logger.Warning("Failed to remove temp symlink test %s: %v", testSymlink, err)
}
diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go
index f9283e64..f0c820c7 100644
--- a/internal/checks/checks_test.go
+++ b/internal/checks/checks_test.go
@@ -891,6 +891,43 @@ func TestCheckTempDirectory_SymlinkSupport(t *testing.T) {
}
}
+func TestCheckTempDirectory_DefersSymlinkCleanupAfterInlineRemoveFailure(t *testing.T) {
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ origTempRoot := tempRootPath
+ origRemove := osRemove
+ t.Cleanup(func() {
+ tempRootPath = origTempRoot
+ osRemove = origRemove
+ })
+
+ tempRootPath = t.TempDir()
+ testSymlink := filepath.Join(tempRootPath, ".proxsave-symlink-test")
+ removeSymlinkCalls := 0
+ osRemove = func(name string) error {
+ if name == testSymlink {
+ removeSymlinkCalls++
+ if removeSymlinkCalls == 1 {
+ return syscall.EIO
+ }
+ }
+ return origRemove(name)
+ }
+
+ checker := NewChecker(logger, GetDefaultCheckerConfig(t.TempDir(), t.TempDir(), t.TempDir()))
+ result := checker.CheckTempDirectory()
+ if !result.Passed {
+ t.Fatalf("expected CheckTempDirectory to pass, got: %v", result.Error)
+ }
+ if removeSymlinkCalls != 2 {
+ t.Fatalf("expected inline and deferred symlink cleanup attempts, got %d", removeSymlinkCalls)
+ }
+ if _, err := os.Lstat(testSymlink); !os.IsNotExist(err) {
+ t.Fatalf("expected deferred symlink cleanup, lstat err=%v", err)
+ }
+}
+
func TestRunAllChecks_IncludesTempDirectory(t *testing.T) {
// Ensure /tmp/proxsave exists
if err := os.MkdirAll(filepath.Join("/tmp", "proxsave"), 0o755); err != nil {
@@ -1190,6 +1227,8 @@ func TestCheckLockFile_CloseFailsRemovesPartialLock(t *testing.T) {
origSync := syncFile
t.Cleanup(func() { syncFile = origSync })
+ // Closing here causes the subsequent production Close() to return os.ErrClosed
+ // and exercise the close-failure branch.
syncFile = func(f *os.File) error {
return f.Close()
}
@@ -1368,6 +1407,52 @@ func TestCheckPermissions_RetriesOnEIOThenSucceeds(t *testing.T) {
}
}
+func TestCheckPermissions_RetriesOnCloseEIOThenSucceeds(t *testing.T) {
+ logger := logging.New(types.LogLevelInfo, false)
+ logger.SetOutput(io.Discard)
+
+ backupDir := t.TempDir()
+ logDir := t.TempDir()
+ config := GetDefaultCheckerConfig(backupDir, logDir, t.TempDir())
+
+ origCreate := createTestFile
+ origClose := closeTestFile
+ t.Cleanup(func() {
+ createTestFile = origCreate
+ closeTestFile = origClose
+ })
+
+ attempts := 0
+ createTestFile = func(name string) (*os.File, error) {
+ attempts++
+ return os.Create(name)
+ }
+
+ closeEIOs := 0
+ closeTestFile = func(f *os.File) error {
+ if err := f.Close(); err != nil {
+ return err
+ }
+ if closeEIOs < 2 {
+ closeEIOs++
+ return syscall.EIO
+ }
+ return nil
+ }
+
+ checker := NewChecker(logger, config)
+ result := checker.CheckPermissions()
+ if !result.Passed {
+ t.Fatalf("expected CheckPermissions to pass after close retries, got: %v", result.Error)
+ }
+ if closeEIOs != 2 {
+ t.Fatalf("expected 2 close EIO attempts, got %d", closeEIOs)
+ }
+ if attempts != 4 {
+ t.Fatalf("expected 4 attempts total (3 for backup dir, 1 for log dir), got %d", attempts)
+ }
+}
+
func TestCheckPermissions_RemoveTestFileWarning(t *testing.T) {
logger := logging.New(types.LogLevelInfo, false)
logger.SetOutput(io.Discard)
diff --git a/internal/config/config.go b/internal/config/config.go
index 7e014cfe..61f3c89a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -17,14 +17,16 @@ import (
)
const (
- telegramEnabledKey = "TELEGRAM_ENABLED"
- telegramEnableLegacyKey = "TELEGRAM_ENABLE"
- emailEnabledKey = "EMAIL_ENABLED"
- emailEnableLegacyKey = "EMAIL_ENABLE"
- gotifyEnabledKey = "GOTIFY_ENABLED"
- gotifyEnableLegacyKey = "GOTIFY_ENABLE"
- webhookEnabledKey = "WEBHOOK_ENABLED"
- webhookEnableLegacyKey = "WEBHOOK_ENABLE"
+ telegramEnabledKey = "TELEGRAM_ENABLED"
+ telegramEnableLegacyKey = "TELEGRAM_ENABLE"
+ emailEnabledKey = "EMAIL_ENABLED"
+ emailEnableLegacyKey = "EMAIL_ENABLE"
+ emailFallbackSendmailKey = "EMAIL_FALLBACK_SENDMAIL"
+ emailFallbackPMFLegacyKey = "EMAIL_FALLBACK_PMF"
+ gotifyEnabledKey = "GOTIFY_ENABLED"
+ gotifyEnableLegacyKey = "GOTIFY_ENABLE"
+ webhookEnabledKey = "WEBHOOK_ENABLED"
+ webhookEnableLegacyKey = "WEBHOOK_ENABLE"
)
var (
@@ -54,6 +56,26 @@ var (
configHostnameFunc = os.Hostname
)
+// NormalizeEmailDeliveryMethod maps user-facing aliases to the internal delivery
+// method identifiers used by the notification runtime.
+func NormalizeEmailDeliveryMethod(method string) string {
+ normalized := strings.ToLower(strings.TrimSpace(method))
+ if normalized == "" {
+ return "relay"
+ }
+ normalized = strings.NewReplacer("_", "-", " ", "-").Replace(normalized)
+ switch normalized {
+ case "relay", "cloud", "cloud-relay", "tis24-relay":
+ return "relay"
+ case "sendmail", "local", "local-mta", "mta":
+ return "sendmail"
+ case "pmf", "pfm", "proxmox", "proxmox-notification", "proxmox-notifications", "proxmox-mail-forward":
+ return "pmf"
+ default:
+ return normalized
+ }
+}
+
// Config contains the full backup system configuration.
type Config struct {
// General settings
@@ -167,7 +189,7 @@ type Config struct {
// Email Notifications
EmailEnabled bool
EmailDeliveryMethod string // "relay", "sendmail", or "pmf"
- EmailFallbackSendmail bool
+ EmailFallbackSendmail bool // True means email delivery may fall back to local /usr/sbin/sendmail.
EmailRecipient string // Single recipient, empty = auto-detect
EmailFrom string
@@ -283,11 +305,20 @@ type Config struct {
PBSFingerprint string // Auto-detected from PBS certificate
// raw configuration map
- raw map[string]string
+ raw map[string]string
+ ignoredBaseDirConfig string
+ ignoredBaseDirEnv string
+ detectedBaseDir string
}
// LoadConfig reads the backup.env configuration file.
func LoadConfig(configPath string) (*Config, error) {
+ return LoadConfigWithBaseDir(configPath, defaultBaseDir())
+}
+
+// LoadConfigWithBaseDir reads the backup.env configuration file and expands
+// ${BASE_DIR} using the caller-provided runtime base directory.
+func LoadConfigWithBaseDir(configPath, detectedBaseDir string) (*Config, error) {
if !utils.FileExists(configPath) {
return nil, fmt.Errorf("configuration file not found: %s", configPath)
}
@@ -297,9 +328,22 @@ func LoadConfig(configPath string) (*Config, error) {
return nil, err
}
+ detectedBaseDir = strings.TrimSpace(detectedBaseDir)
+ if detectedBaseDir == "" {
+ detectedBaseDir = defaultBaseDir()
+ }
+
cfg := &Config{
- ConfigPath: configPath,
- raw: rawValues,
+ ConfigPath: configPath,
+ BaseDir: detectedBaseDir,
+ raw: rawValues,
+ detectedBaseDir: detectedBaseDir,
+ }
+ if rawBaseDir := strings.TrimSpace(rawValues["BASE_DIR"]); rawBaseDir != "" {
+ cfg.ignoredBaseDirConfig = rawBaseDir
+ }
+ if envBaseDir := strings.TrimSpace(os.Getenv("BASE_DIR")); envBaseDir != "" && filepath.Clean(envBaseDir) != filepath.Clean(detectedBaseDir) {
+ cfg.ignoredBaseDirEnv = envBaseDir
}
// Override with environment variables (env vars take precedence over file)
@@ -336,7 +380,7 @@ func (c *Config) loadEnvOverrides() {
"RETENTION_DAILY", "RETENTION_WEEKLY", "RETENTION_MONTHLY", "RETENTION_YEARLY",
"BUNDLE_ASSOCIATED_FILES", "ENCRYPT_ARCHIVE", "AGE_RECIPIENT", "AGE_RECIPIENT_FILE",
"TELEGRAM_ENABLE", "TELEGRAM_ENABLED", "BOT_TELEGRAM_TYPE", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID",
- "EMAIL_ENABLE", "EMAIL_ENABLED", "EMAIL_DELIVERY_METHOD", "EMAIL_FALLBACK_SENDMAIL",
+ "EMAIL_ENABLE", "EMAIL_ENABLED", "EMAIL_DELIVERY_METHOD", "EMAIL_FALLBACK_PMF", "EMAIL_FALLBACK_SENDMAIL",
"EMAIL_RECIPIENT", "EMAIL_FROM",
"GOTIFY_ENABLE", "GOTIFY_ENABLED", "GOTIFY_SERVER_URL", "GOTIFY_TOKEN",
"GOTIFY_PRIORITY_SUCCESS", "GOTIFY_PRIORITY_WARNING", "GOTIFY_PRIORITY_FAILURE",
@@ -521,9 +565,11 @@ func (c *Config) parseOptimizationSettings() {
func (c *Config) parseSecuritySettings() {
c.DisableNetworkPreflight = c.getBool("DISABLE_NETWORK_PREFLIGHT", false)
- // Base directory
- envBaseDir := os.Getenv("BASE_DIR")
- c.BaseDir = c.getString("BASE_DIR", envBaseDir)
+ // Base directory is runtime-derived. BASE_DIR in backup.env or the parent
+ // environment is deprecated and intentionally ignored.
+ if c.BaseDir == "" {
+ c.BaseDir = strings.TrimSpace(c.detectedBaseDir)
+ }
if c.BaseDir == "" {
c.BaseDir = defaultBaseDir()
}
@@ -673,8 +719,8 @@ func (c *Config) parseNotificationSettings() {
c.ServerID = ""
c.EmailEnabled = c.getBoolWithLegacyAlias(emailEnabledKey, emailEnableLegacyKey, false)
- c.EmailDeliveryMethod = c.getString("EMAIL_DELIVERY_METHOD", "relay")
- c.EmailFallbackSendmail = c.getBool("EMAIL_FALLBACK_SENDMAIL", true)
+ c.EmailDeliveryMethod = NormalizeEmailDeliveryMethod(c.getString("EMAIL_DELIVERY_METHOD", "relay"))
+ c.EmailFallbackSendmail = c.getBoolWithFallback([]string{emailFallbackSendmailKey, emailFallbackPMFLegacyKey}, true)
c.EmailRecipient = c.getString("EMAIL_RECIPIENT", "")
c.EmailFrom = c.getString("EMAIL_FROM", "no-reply@proxmox.tis24.it")
@@ -973,9 +1019,6 @@ func mergeStringSlices(base, extra []string) []string {
// Helper methods with fallback support (try multiple keys)
func defaultBaseDir() string {
- if val := strings.TrimSpace(os.Getenv("BASE_DIR")); val != "" {
- return val
- }
if _, err := os.Stat("/opt/proxsave"); err == nil {
return "/opt/proxsave"
}
@@ -985,29 +1028,17 @@ func defaultBaseDir() string {
return "/opt/proxsave"
}
-// expandEnvVars expands environment variables and special variables like ${BASE_DIR}
-func expandEnvVars(s string) string {
- // Expand ${VAR} and $VAR style variables
- result := os.Expand(s, func(key string) string {
- // Special handling for BASE_DIR
- if key == "BASE_DIR" {
- // Check if BASE_DIR is set in environment, otherwise use default
- return defaultBaseDir()
- }
- return os.Getenv(key)
- })
- return result
-}
-
type configVarExpander struct {
raw map[string]string
+ baseDir string
cache map[string]string
inProgress map[string]bool
}
-func newConfigVarExpander(raw map[string]string) *configVarExpander {
+func newConfigVarExpander(raw map[string]string, baseDir string) *configVarExpander {
return &configVarExpander{
raw: raw,
+ baseDir: strings.TrimSpace(baseDir),
cache: make(map[string]string),
inProgress: make(map[string]bool),
}
@@ -1035,15 +1066,11 @@ func (e *configVarExpander) resolve(key string) string {
e.inProgress[upperKey] = true
defer delete(e.inProgress, upperKey)
- // Keep the historical behavior where BASE_DIR expands even when it's not set
- // in the config or environment.
if upperKey == "BASE_DIR" {
- if rawVal, ok := e.raw[upperKey]; ok && strings.TrimSpace(rawVal) != "" {
- expanded := e.expand(rawVal)
- e.cache[upperKey] = expanded
- return expanded
+ expanded := strings.TrimSpace(e.baseDir)
+ if expanded == "" {
+ expanded = defaultBaseDir()
}
- expanded := defaultBaseDir()
e.cache[upperKey] = expanded
return expanded
}
@@ -1066,7 +1093,7 @@ func (c *Config) expandConfigVars(s string) string {
if strings.IndexByte(s, '$') == -1 {
return s
}
- return newConfigVarExpander(c.raw).expand(s)
+ return newConfigVarExpander(c.raw, c.BaseDir).expand(s)
}
func (c *Config) getStringWithFallback(keys []string, defaultValue string) string {
@@ -1226,6 +1253,24 @@ func (c *Config) Get(key string) (string, bool) {
return val, ok
}
+// IgnoredBaseDirConfig returns the deprecated BASE_DIR value found in
+// backup.env, if any. The value is informational only and is not applied.
+func (c *Config) IgnoredBaseDirConfig() (string, bool) {
+ if c == nil || strings.TrimSpace(c.ignoredBaseDirConfig) == "" {
+ return "", false
+ }
+ return c.ignoredBaseDirConfig, true
+}
+
+// IgnoredBaseDirEnv returns the deprecated BASE_DIR value inherited from the
+// parent environment when it differs from the detected runtime base directory.
+func (c *Config) IgnoredBaseDirEnv() (string, bool) {
+ if c == nil || strings.TrimSpace(c.ignoredBaseDirEnv) == "" {
+ return "", false
+ }
+ return c.ignoredBaseDirEnv, true
+}
+
// Set sets a value in the configuration.
func (c *Config) Set(key, value string) {
c.raw[key] = value
@@ -1654,5 +1699,3 @@ func (c *Config) GetRetentionPolicy() string {
}
return "simple"
}
-
-// expandEnvVars expands environment variables and special variables like ${BASE_DIR}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index e9e6bce0..2266cf49 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -78,8 +78,9 @@ BACKUP_BLACKLIST=/var/data/tmp
}
setBaseDirEnv(t, "/env/base/dir")
+ detectedBaseDir := "/detected/base/dir"
- cfg, err := LoadConfig(configPath)
+ cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -129,8 +130,8 @@ BACKUP_BLACKLIST=/var/data/tmp
t.Error("Expected MetricsEnabled to be true")
}
- if cfg.BaseDir != "/env/base/dir" {
- t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, "/env/base/dir")
+ if cfg.BaseDir != detectedBaseDir {
+ t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir)
}
if !cfg.SecurityCheckEnabled {
@@ -222,7 +223,7 @@ PBS_DATASTORE_PATH=/mnt/pbs1,/mnt/pbs2,/mnt/pbs3
t.Fatalf("Failed to create config file: %v", err)
}
- cfg, err := LoadConfig(configPath)
+ cfg, err := LoadConfigWithBaseDir(configPath, "/custom/base")
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -279,7 +280,8 @@ AGE_RECIPIENT_FILE=${BASE_DIR}/identity/age/recipient.txt
setBaseDirEnv(t, "/custom/base")
- cfg, err := LoadConfig(configPath)
+ detectedBaseDir := "/custom/base"
+ cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -316,7 +318,8 @@ SECONDARY_PATH=remote:path
t.Fatalf("Failed to create config file: %v", err)
}
- cfg, err := LoadConfig(configPath)
+ detectedBaseDir := "/quotes/base"
+ cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -409,7 +412,8 @@ LOG_PATH=/path/without/quotes
setBaseDirEnv(t, "/quotes/base")
- cfg, err := LoadConfig(configPath)
+ detectedBaseDir := "/quotes/base"
+ cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -426,8 +430,8 @@ LOG_PATH=/path/without/quotes
t.Errorf("LogPath = %q; want %q", cfg.LogPath, "/path/without/quotes")
}
- if cfg.BaseDir != "/quotes/base" {
- t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, "/quotes/base")
+ if cfg.BaseDir != detectedBaseDir {
+ t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir)
}
}
@@ -451,7 +455,7 @@ DEBUG_LEVEL=4
setBaseDirEnv(t, "/comments/base")
- cfg, err := LoadConfig(configPath)
+ cfg, err := LoadConfigWithBaseDir(configPath, "/defaults/base")
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -504,7 +508,8 @@ func TestConfigDefaults(t *testing.T) {
setBaseDirEnv(t, "/defaults/base")
- cfg, err := LoadConfig(configPath)
+ detectedBaseDir := "/defaults/base"
+ cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
@@ -534,8 +539,8 @@ func TestConfigDefaults(t *testing.T) {
t.Error("Expected default EmailEnabled to be false")
}
- if cfg.BaseDir != "/defaults/base" {
- t.Errorf("Default BaseDir = %q; want %q", cfg.BaseDir, "/defaults/base")
+ if cfg.BaseDir != detectedBaseDir {
+ t.Errorf("Default BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir)
}
}
@@ -573,6 +578,54 @@ WEBHOOK_ENABLE=true
}
}
+func TestLoadConfigEmailDeliveryAliasesAndFallbackSendmail(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "email_aliases.env")
+
+ content := `EMAIL_ENABLED=true
+EMAIL_DELIVERY_METHOD=proxmox-notifications
+EMAIL_FALLBACK_SENDMAIL=false
+EMAIL_FALLBACK_PMF=true
+`
+ if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
+ t.Fatalf("Failed to create test config: %v", err)
+ }
+
+ setBaseDirEnv(t, "/email/aliases/base")
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+
+ if cfg.EmailDeliveryMethod != "pmf" {
+ t.Fatalf("EmailDeliveryMethod=%q, want pmf", cfg.EmailDeliveryMethod)
+ }
+ if cfg.EmailFallbackSendmail {
+ t.Fatalf("EMAIL_FALLBACK_SENDMAIL should take precedence over transitional EMAIL_FALLBACK_PMF")
+ }
+}
+
+func TestNormalizeEmailDeliveryMethod(t *testing.T) {
+ tests := map[string]string{
+ "": "relay",
+ "cloud relay": "relay",
+ "local_mta": "sendmail",
+ "pfm": "pmf",
+ "proxmox": "pmf",
+ "proxmox-mail-forward": "pmf",
+ "proxmox notifications": "pmf",
+ "unexpected-experiment": "unexpected-experiment",
+ " unexpected_EXPERIMENT ": "unexpected-experiment",
+ }
+
+ for input, want := range tests {
+ if got := NormalizeEmailDeliveryMethod(input); got != want {
+ t.Fatalf("NormalizeEmailDeliveryMethod(%q)=%q, want %q", input, got, want)
+ }
+ }
+}
+
func TestLoadEnvOverridesNotificationLegacyEnableAliases(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "legacy_notification_env_override.env")
@@ -620,7 +673,7 @@ BOT_TELEGRAM_TYPE=centralized
}
}
-func TestLoadConfigBaseDirFromConfig(t *testing.T) {
+func TestLoadConfigIgnoresBaseDirFromConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "base_dir.env")
@@ -631,19 +684,26 @@ BACKUP_PATH=${BASE_DIR}/backup-data
t.Fatalf("Failed to create test config: %v", err)
}
- setBaseDirEnv(t, "")
+ setBaseDirEnv(t, "/env/base")
+ detectedBaseDir := "/detected/base"
- cfg, err := LoadConfig(configPath)
+ cfg, err := LoadConfigWithBaseDir(configPath, detectedBaseDir)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- if cfg.BaseDir != "/custom/base" {
- t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, "/custom/base")
+ if cfg.BaseDir != detectedBaseDir {
+ t.Errorf("BaseDir = %q; want %q", cfg.BaseDir, detectedBaseDir)
}
- if cfg.BackupPath != "/custom/base/backup-data" {
- t.Errorf("BackupPath = %q; want %q", cfg.BackupPath, "/custom/base/backup-data")
+ if cfg.BackupPath != "/detected/base/backup-data" {
+ t.Errorf("BackupPath = %q; want %q", cfg.BackupPath, "/detected/base/backup-data")
+ }
+ if val, ok := cfg.IgnoredBaseDirConfig(); !ok || val != "/custom/base" {
+ t.Fatalf("IgnoredBaseDirConfig = %q, %v; want /custom/base true", val, ok)
+ }
+ if val, ok := cfg.IgnoredBaseDirEnv(); !ok || val != "/env/base" {
+ t.Fatalf("IgnoredBaseDirEnv = %q, %v; want /env/base true", val, ok)
}
}
@@ -957,18 +1017,16 @@ func TestConfigFallbackHelpers(t *testing.T) {
}
}
-func TestExpandEnvVarsAndBaseDir(t *testing.T) {
- setBaseDirEnv(t, "/env/base")
-
+func TestConfigVarExpanderUsesDetectedBaseDir(t *testing.T) {
t.Setenv("FOO", "bar")
in := "${FOO}/$FOO/${BASE_DIR}/suffix"
- got := expandEnvVars(in)
+ got := newConfigVarExpander(map[string]string{}, "/detected/base").expand(in)
if !strings.Contains(got, "bar/bar") {
t.Fatalf("expandEnvVars FOO not expanded correctly: %q", got)
}
- if !strings.Contains(got, "/env/base/") {
- t.Fatalf("expandEnvVars BASE_DIR not expanded from env base dir: %q", got)
+ if !strings.Contains(got, "/detected/base/") {
+ t.Fatalf("expandEnvVars BASE_DIR not expanded from detected base dir: %q", got)
}
}
diff --git a/internal/config/env_mutation.go b/internal/config/env_mutation.go
index 1fca9a80..6f474d42 100644
--- a/internal/config/env_mutation.go
+++ b/internal/config/env_mutation.go
@@ -41,3 +41,50 @@ func ApplySecondaryStorageSettings(template string, enabled bool, secondaryPath
template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", "")
return template
}
+
+// RemoveEnvKeys removes active KEY=VALUE entries from an env template while
+// preserving comments and unrelated lines.
+func RemoveEnvKeys(template string, keys ...string) string {
+ if len(keys) == 0 {
+ return template
+ }
+ remove := make(map[string]struct{}, len(keys))
+ for _, key := range keys {
+ key = strings.ToUpper(strings.TrimSpace(key))
+ if key != "" {
+ remove[key] = struct{}{}
+ }
+ }
+ if len(remove) == 0 {
+ return template
+ }
+
+ lines := strings.Split(template, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if utils.IsComment(trimmed) {
+ out = append(out, line)
+ continue
+ }
+ key, _, ok := utils.SplitKeyValue(line)
+ if !ok {
+ out = append(out, line)
+ continue
+ }
+ if fields := strings.Fields(key); len(fields) >= 2 && fields[0] == "export" {
+ key = fields[1]
+ }
+ if _, drop := remove[strings.ToUpper(strings.TrimSpace(key))]; drop {
+ continue
+ }
+ out = append(out, line)
+ }
+ return strings.Join(out, "\n")
+}
+
+// RemoveRuntimeDerivedEnvKeys strips config keys that are intentionally derived
+// at runtime instead of stored in backup.env.
+func RemoveRuntimeDerivedEnvKeys(template string) string {
+ return RemoveEnvKeys(template, "BASE_DIR", "CRON_SCHEDULE", "CRON_HOUR", "CRON_MINUTE")
+}
diff --git a/internal/config/migration.go b/internal/config/migration.go
index e8448578..631724b5 100644
--- a/internal/config/migration.go
+++ b/internal/config/migration.go
@@ -149,6 +149,9 @@ func mergeTemplateWithLegacy(template string, legacy map[string]string) (string,
}
for key := range legacy {
+ if _, ignored := ignoredLegacyMigrationKeys[strings.ToUpper(strings.TrimSpace(key))]; ignored {
+ continue
+ }
if !usedLegacy[key] {
summary.UnmappedLegacyKeys = append(summary.UnmappedLegacyKeys, key)
}
@@ -162,6 +165,10 @@ func mergeTemplateWithLegacy(template string, legacy map[string]string) (string,
return merged, summary
}
+var ignoredLegacyMigrationKeys = map[string]struct{}{
+ "BASE_DIR": {},
+}
+
func renderValueLines(key, value string) []string {
if blockValueKeys[key] {
blockLines := strings.Split(value, "\n")
diff --git a/internal/config/migration_test.go b/internal/config/migration_test.go
index a4bd4fbe..2218b259 100644
--- a/internal/config/migration_test.go
+++ b/internal/config/migration_test.go
@@ -155,6 +155,21 @@ func TestMergeTemplateWithLegacyTracksUnmappedKeys(t *testing.T) {
}
}
+func TestMergeTemplateWithLegacyIgnoresDeprecatedBaseDir(t *testing.T) {
+ legacy := map[string]string{
+ "BASE_DIR": "/legacy/base",
+ }
+
+ merged, summary := mergeTemplateWithLegacy(testTemplate, legacy)
+
+ if strings.Contains(merged, "\nBASE_DIR=") || strings.HasPrefix(merged, "BASE_DIR=") {
+ t.Fatalf("merged template should not contain active BASE_DIR:\n%s", merged)
+ }
+ if len(summary.UnmappedLegacyKeys) != 0 {
+ t.Fatalf("UnmappedLegacyKeys = %v; want []", summary.UnmappedLegacyKeys)
+ }
+}
+
const baseInstallTemplate = `BACKUP_ENABLED=true
BACKUP_PATH=/default/backup
LOG_PATH=/default/log
diff --git a/internal/config/templates/backup.env b/internal/config/templates/backup.env
index 0089f6ed..2f7be435 100644
--- a/internal/config/templates/backup.env
+++ b/internal/config/templates/backup.env
@@ -49,6 +49,8 @@ MIN_DISK_SPACE_CLOUD_GB=1
# ----------------------------------------------------------------------
# Local paths
# ----------------------------------------------------------------------
+# BASE_DIR is auto-detected from the installed proxsave executable; do not set it in backup.env.
+# BASE_DIR=/opt/proxsave
LOCK_PATH=${BASE_DIR}/lock
SECURE_ACCOUNT=${BASE_DIR}/secure_account
@@ -222,9 +224,9 @@ TELEGRAM_CHAT_ID= # For personal mode only
# Email Notifications
EMAIL_ENABLED=false
-EMAIL_DELIVERY_METHOD=relay # "relay" (cloud relay) | "sendmail" (/usr/sbin/sendmail) | "pmf" (proxmox-mail-forward / Proxmox Notifications)
-EMAIL_FALLBACK_SENDMAIL=true # Historical name: when EMAIL_DELIVERY_METHOD=relay, fallback to "pmf" (proxmox-mail-forward) if relay fails
-EMAIL_RECIPIENT= # Leave empty for auto-detection from Proxmox
+EMAIL_DELIVERY_METHOD=relay # "relay" = TIS24 HTTPS relay (default) | "sendmail" = local /usr/sbin/sendmail | "pmf" = Proxmox Notifications
+EMAIL_FALLBACK_SENDMAIL=true # Fall back to local sendmail when relay fails; if method=pmf, fallback order is relay then sendmail
+EMAIL_RECIPIENT= # relay/sendmail require a recipient (empty = root@pam auto-detect); pmf uses this only as a To: header
EMAIL_FROM=no-reply@proxmox.tis24.it
# Gotify Notifications
@@ -375,7 +377,7 @@ BACKUP_BLACKLIST="
SKIP_PERMISSION_CHECK=false # true = skip permission checks (test only)
BACKUP_USER=backup # Owner user for backup/log directories
BACKUP_GROUP=backup # Owner group for backup/log directories
-SET_BACKUP_PERMISSIONS=false # true = apply Bash-style chown/chmod on backup/log
+SET_BACKUP_PERMISSIONS=false # true = apply Bash-style chown/chmod on backup/log (skips CIFS/SMB/NTFS/FUSE)
# ==============================================================================
# End file
diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go
index 0a402f47..7f50d770 100644
--- a/internal/config/upgrade.go
+++ b/internal/config/upgrade.go
@@ -74,6 +74,12 @@ type UpgradeResult struct {
// to a "Custom keys" section at the bottom of the file.
// 4. The original file is backed up before writing the new version.
func UpgradeConfigFile(configPath string) (*UpgradeResult, error) {
+ return UpgradeConfigFileWithBaseDir(configPath, defaultBaseDir())
+}
+
+// UpgradeConfigFileWithBaseDir merges the user's configuration with the
+// embedded template and validates the result against the detected base dir.
+func UpgradeConfigFileWithBaseDir(configPath, baseDir string) (*UpgradeResult, error) {
result, newContent, originalContent, err := computeConfigUpgrade(configPath)
if err != nil {
return result, err
@@ -113,7 +119,7 @@ func UpgradeConfigFile(configPath string) (*UpgradeResult, error) {
result.BackupPath = backupPath
// Post-upgrade validation: ensure the upgraded configuration can be parsed.
- if _, err := LoadConfig(configPath); err != nil {
+ if _, err := LoadConfigWithBaseDir(configPath, baseDir); err != nil {
// Attempt automatic rollback to the backup.
_ = os.Rename(backupPath, configPath)
return result, fmt.Errorf("upgraded config invalid, restored backup: %w", err)
@@ -175,7 +181,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er
// Prune deprecated keys that are now auto-detected at runtime.
//
- // BASE_DIR is derived from the executable/config location.
+ // BASE_DIR is derived from the installed executable path.
// CRON_* scheduling is managed via crontab, not backup.env.
deprecatedUpperKeys := map[string]struct{}{
"BASE_DIR": {},
@@ -211,7 +217,7 @@ func computeConfigUpgrade(configPath string) (*UpgradeResult, string, []byte, er
keys = append(keys, k)
}
sort.Strings(keys)
- warnings = append(warnings, fmt.Sprintf("Removed deprecated keys from backup.env: %s (BASE_DIR is auto-detected; cron is managed via crontab)", strings.Join(keys, ", ")))
+ warnings = append(warnings, fmt.Sprintf("Removed deprecated keys from backup.env: %s (BASE_DIR is auto-detected from the installed executable; cron is managed via crontab)", strings.Join(keys, ", ")))
}
// 2. Walk the template line-by-line and collect template entries.
diff --git a/internal/identity/identity.go b/internal/identity/identity.go
index 22a9d546..d0168dda 100644
--- a/internal/identity/identity.go
+++ b/internal/identity/identity.go
@@ -310,21 +310,29 @@ func isVirtualInterface(iface string, logger *logging.Logger) bool {
func isBridgeInterface(iface string) bool {
if runtime.GOOS != "linux" {
- name := strings.ToLower(iface)
- return strings.HasPrefix(name, "vmbr") || strings.HasPrefix(name, "br") || strings.HasPrefix(name, "bridge")
+ return isBridgeInterfaceByName(iface)
}
_, err := os.Stat(filepath.Join("/sys/class/net", iface, "bridge"))
return err == nil
}
+func isBridgeInterfaceByName(iface string) bool {
+ name := strings.ToLower(iface)
+ return strings.HasPrefix(name, "vmbr") || strings.HasPrefix(name, "br") || strings.HasPrefix(name, "bridge")
+}
+
func isWirelessInterface(iface string) bool {
if runtime.GOOS != "linux" {
- return strings.HasPrefix(strings.ToLower(iface), "wl")
+ return isWirelessInterfaceByName(iface)
}
_, err := os.Stat(filepath.Join("/sys/class/net", iface, "wireless"))
return err == nil
}
+func isWirelessInterfaceByName(iface string) bool {
+ return strings.HasPrefix(strings.ToLower(iface), "wl")
+}
+
func isLocallyAdministeredMAC(mac string) bool {
fields := strings.Split(mac, ":")
if len(fields) == 0 {
diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go
index 25bad342..9eff5ea2 100644
--- a/internal/identity/identity_test.go
+++ b/internal/identity/identity_test.go
@@ -1138,8 +1138,6 @@ func TestReadAddrAssignType(t *testing.T) {
}
func TestIsBridgeInterfaceByName(t *testing.T) {
- // On non-Linux, detection falls back to interface names. On Linux,
- // sysfs decides the result and these synthetic names may not exist.
tests := []struct {
name string
want bool
@@ -1156,21 +1154,15 @@ func TestIsBridgeInterfaceByName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := isBridgeInterface(tt.name)
- if runtime.GOOS == "linux" {
- t.Logf("sysfs bridge detection for %q returned %v", tt.name, got)
- return
- }
+ got := isBridgeInterfaceByName(tt.name)
if got != tt.want {
- t.Fatalf("isBridgeInterface(%q)=%v; want %v", tt.name, got, tt.want)
+ t.Fatalf("isBridgeInterfaceByName(%q)=%v; want %v", tt.name, got, tt.want)
}
})
}
}
func TestIsWirelessInterfaceByName(t *testing.T) {
- // On non-Linux, detection falls back to interface names. On Linux,
- // sysfs decides the result and these synthetic names may not exist.
tests := []struct {
name string
want bool
@@ -1184,13 +1176,9 @@ func TestIsWirelessInterfaceByName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := isWirelessInterface(tt.name)
- if runtime.GOOS == "linux" {
- t.Logf("sysfs wireless detection for %q returned %v", tt.name, got)
- return
- }
+ got := isWirelessInterfaceByName(tt.name)
if got != tt.want {
- t.Fatalf("isWirelessInterface(%q)=%v; want %v", tt.name, got, tt.want)
+ t.Fatalf("isWirelessInterfaceByName(%q)=%v; want %v", tt.name, got, tt.want)
}
})
}
diff --git a/internal/logging/logger.go b/internal/logging/logger.go
index 2e80b719..19ca0a6e 100644
--- a/internal/logging/logger.go
+++ b/internal/logging/logger.go
@@ -72,10 +72,11 @@ func (l *Logger) OpenLogFile(logPath string) error {
defer l.mu.Unlock()
// If a log file is already open, close it first.
if l.logFile != nil {
- if err := l.logFile.Close(); err != nil {
+ err := l.logFile.Close()
+ l.logFile = nil
+ if err != nil {
return fmt.Errorf("failed to close existing log file: %w", err)
}
- l.logFile = nil
}
// Create the log file (O_CREATE|O_WRONLY|O_APPEND).
diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go
index 89f426e1..043ea07a 100644
--- a/internal/logging/logger_test.go
+++ b/internal/logging/logger_test.go
@@ -477,6 +477,27 @@ func TestOpenLogFileReopenClosesPrevious(t *testing.T) {
}
}
+func TestOpenLogFileClearsStaleHandleWhenCloseFails(t *testing.T) {
+ tmp := t.TempDir()
+ first := filepath.Join(tmp, "first.log")
+ second := filepath.Join(tmp, "second.log")
+
+ logger := New(types.LogLevelInfo, false)
+ if err := logger.OpenLogFile(first); err != nil {
+ t.Fatalf("OpenLogFile(first) error: %v", err)
+ }
+ if err := logger.logFile.Close(); err != nil {
+ t.Fatalf("pre-close log file: %v", err)
+ }
+
+ if err := logger.OpenLogFile(second); err == nil {
+ t.Fatalf("expected reopen to report close error")
+ }
+ if logger.logFile != nil {
+ t.Fatalf("expected stale logFile handle to be cleared")
+ }
+}
+
func TestPackageLevelFatalUsesDefaultLoggerExitFunc(t *testing.T) {
var buf bytes.Buffer
logger := New(types.LogLevelDebug, false)
diff --git a/internal/notify/email.go b/internal/notify/email.go
index e04644da..16d3b458 100644
--- a/internal/notify/email.go
+++ b/internal/notify/email.go
@@ -165,8 +165,8 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
switch e.config.DeliveryMethod {
case EmailDeliveryRelay:
if e.config.FallbackSendmail {
- e.logger.Info("Email delivery method: relay (fallback: pmf enabled)")
- e.logger.Debug("Email delivery plan: primary=relay fallback=pmf relay_requires_recipient=true pmf_recipient_optional=true")
+ e.logger.Info("Email delivery method: relay (fallback: sendmail enabled)")
+ e.logger.Debug("Email delivery plan: primary=relay fallback=sendmail relay_requires_recipient=true sendmail_requires_recipient=true")
} else {
e.logger.Info("Email delivery method: relay (fallback: disabled)")
e.logger.Debug("Email delivery plan: primary=relay fallback=disabled relay_requires_recipient=true")
@@ -176,7 +176,11 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
e.logger.Debug("Email delivery plan: primary=sendmail fallback=disabled relay_requires_recipient=true")
case EmailDeliveryPMF:
e.logger.Info("Email delivery method: pmf (proxmox-mail-forward)")
- e.logger.Debug("Email delivery plan: primary=pmf fallback=disabled recipient_optional=true")
+ if e.config.FallbackSendmail {
+ e.logger.Debug("Email delivery plan: primary=pmf fallback=relay,sendmail recipient_optional=true relay_requires_recipient=true sendmail_requires_recipient=true")
+ } else {
+ e.logger.Debug("Email delivery plan: primary=pmf fallback=relay recipient_optional=true relay_requires_recipient=true")
+ }
default:
e.logger.Info("Email delivery method: %s", e.config.DeliveryMethod)
}
@@ -185,7 +189,6 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
recipient := strings.TrimSpace(e.config.Recipient)
autoDetected := false
recipientSource := "configured"
- relayPMFFallbackEnabled := e.config.DeliveryMethod == EmailDeliveryRelay && e.config.FallbackSendmail
var preflightFallbackReason string
var preflightFallbackCause error
if recipient == "" {
@@ -197,12 +200,6 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
switch {
case e.config.DeliveryMethod == EmailDeliveryPMF:
e.logger.Info(" Proceeding anyway because EMAIL_DELIVERY_METHOD=pmf routes via Proxmox Notifications; recipient is only used for the To: header")
- case relayPMFFallbackEnabled:
- preflightFallbackReason = "recipient_autodetect_failed"
- preflightFallbackCause = fmt.Errorf("no valid email recipient: %w", err)
- e.logger.Warning("WARNING: Relay delivery requires a valid recipient; auto-detection failed")
- e.logger.Info(" Bypassing relay and invoking PMF fallback before relay attempt")
- e.logger.Debug("Email fallback decision: stage=preflight reason=%s cause=%v", preflightFallbackReason, preflightFallbackCause)
default:
e.logger.Warning("WARNING: Email notification skipped because no valid recipient is available")
e.logger.Info(" Configure EMAIL_RECIPIENT or set an email address for root@pam inside Proxmox")
@@ -222,16 +219,6 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
recipient = strings.TrimSpace(recipient)
if recipient == "" {
switch {
- case relayPMFFallbackEnabled:
- if preflightFallbackReason == "" {
- preflightFallbackReason = "recipient_missing"
- }
- if preflightFallbackCause == nil {
- preflightFallbackCause = fmt.Errorf("no valid email recipient configured")
- }
- e.logger.Warning("WARNING: Email recipient is empty after configuration/detection")
- e.logger.Info(" Bypassing relay and invoking PMF fallback before relay attempt because proxmox-mail-forward routes via Proxmox Notifications and does not require a resolved recipient")
- e.logger.Debug("Email fallback decision: stage=preflight reason=%s cause=%v", preflightFallbackReason, preflightFallbackCause)
case e.config.DeliveryMethod == EmailDeliveryRelay || e.config.DeliveryMethod == EmailDeliverySendmail:
e.logger.Warning("WARNING: Email recipient is empty after configuration/detection")
e.logger.Info(" Configure EMAIL_RECIPIENT or set an email address for root@pam inside Proxmox")
@@ -247,7 +234,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
if e.config.DeliveryMethod == EmailDeliveryRelay && isRootRecipient(recipient) {
redactedRecipient := redactEmail(recipient)
- if relayPMFFallbackEnabled {
+ if e.config.FallbackSendmail {
if autoDetected {
e.logger.Warning("WARNING: Auto-detected recipient %s belongs to root and is blocked for relay delivery", redactedRecipient)
} else {
@@ -255,7 +242,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
}
preflightFallbackReason = "recipient_blocked_root"
preflightFallbackCause = fmt.Errorf("recipient %s is not allowed (root accounts are blocked)", redactedRecipient)
- e.logger.Info(" Bypassing relay and invoking PMF fallback before relay attempt")
+ e.logger.Info(" Bypassing relay and invoking sendmail fallback before relay attempt")
e.logger.Debug("Email fallback decision: stage=preflight reason=%s cause=%v", preflightFallbackReason, preflightFallbackCause)
} else {
if autoDetected {
@@ -295,19 +282,19 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
var relayErr error // Store original relay error if fallback is used
if preflightFallbackReason != "" {
- err = e.sendViaPMFFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "preflight", preflightFallbackReason, preflightFallbackCause)
+ err = e.sendViaSendmailFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "preflight", preflightFallbackReason, preflightFallbackCause)
} else if e.config.DeliveryMethod == EmailDeliveryRelay {
result.Method = "email-relay"
err = e.sendViaRelay(ctx, recipient, subject, htmlBody, textBody, data)
- // Fallback to PMF if relay fails and fallback is enabled
+ // Fallback to local sendmail if relay fails and fallback is enabled.
if err != nil && e.config.FallbackSendmail {
relayErr = err // Store original relay error
e.logger.Warning("WARNING: Cloud relay failed: %v", err)
- e.logger.Info("Attempting PMF fallback after relay delivery failure...")
+ e.logger.Info("Attempting sendmail fallback after relay delivery failure...")
e.logger.Debug("Email fallback decision: stage=delivery reason=relay_send_failed cause=%v", relayErr)
- err = e.sendViaPMFFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "relay_send_failed", relayErr)
+ err = e.sendViaSendmailFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "relay_send_failed", relayErr)
}
} else if e.config.DeliveryMethod == EmailDeliveryPMF {
result.Method = "email-pmf"
@@ -319,6 +306,10 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
result.Metadata["email_backend_path"] = backendPath
}
err = sendErr
+ if err != nil {
+ e.logger.Warning("WARNING: PMF delivery failed: %v", err)
+ err = e.sendPMFFallbackChain(ctx, result, recipient, subject, htmlBody, textBody, data, err)
+ }
} else {
result.Method = "email-sendmail"
queueID, backend, backendPath, sendErr := e.sendViaSendmail(ctx, recipient, subject, htmlBody, textBody, data)
@@ -348,7 +339,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
// Success (either primary or fallback)
if result.UsedFallback {
// Fallback succeeded after relay failure
- e.logger.Warning("⚠️ Email sent via fallback after relay failure")
+ e.logger.Warning("⚠️ Email sent via fallback after primary delivery failure")
}
// Log according to delivery method to avoid implying guaranteed inbox delivery
@@ -373,7 +364,7 @@ func (e *EmailNotifier) Send(ctx context.Context, data *NotificationData) (*Noti
return result, nil
}
-func (e *EmailNotifier) sendViaPMFFallback(
+func (e *EmailNotifier) sendViaSendmailFallback(
ctx context.Context,
result *NotificationResult,
recipient, subject, htmlBody, textBody string,
@@ -381,7 +372,7 @@ func (e *EmailNotifier) sendViaPMFFallback(
stage, reason string,
cause error,
) error {
- result.Method = "email-pmf-fallback"
+ result.Method = "email-sendmail-fallback"
result.UsedFallback = true
if stage != "" {
result.Metadata["fallback_stage"] = stage
@@ -392,14 +383,17 @@ func (e *EmailNotifier) sendViaPMFFallback(
switch stage {
case "preflight":
- e.logger.Info("PMF fallback activated during preflight (%s)", reason)
+ e.logger.Info("Sendmail fallback activated during preflight (%s)", reason)
case "delivery":
- e.logger.Info("PMF fallback activated after relay delivery failure")
+ e.logger.Info("Sendmail fallback activated after primary delivery failure")
default:
- e.logger.Info("PMF fallback activated")
+ e.logger.Info("Sendmail fallback activated")
}
- backend, backendPath, err := e.sendViaPMF(ctx, recipient, subject, htmlBody, textBody, data)
+ queueID, backend, backendPath, err := e.sendViaSendmail(ctx, recipient, subject, htmlBody, textBody, data)
+ if queueID != "" {
+ result.Metadata["mail_queue_id"] = queueID
+ }
if backend != "" {
result.Metadata["email_backend"] = backend
}
@@ -412,6 +406,71 @@ func (e *EmailNotifier) sendViaPMFFallback(
return err
}
+func (e *EmailNotifier) sendViaRelayFallback(
+ ctx context.Context,
+ result *NotificationResult,
+ recipient, subject, htmlBody, textBody string,
+ data *NotificationData,
+ stage, reason string,
+ cause error,
+) error {
+ result.Method = "email-relay-fallback"
+ result.UsedFallback = true
+ if stage != "" {
+ result.Metadata["fallback_stage"] = stage
+ }
+ if reason != "" {
+ result.Metadata["fallback_reason"] = reason
+ }
+
+ e.logger.Info("Relay fallback activated after PMF delivery failure")
+ err := e.sendViaRelay(ctx, recipient, subject, htmlBody, textBody, data)
+ if err == nil && cause != nil {
+ result.Error = cause
+ }
+ return err
+}
+
+func (e *EmailNotifier) sendPMFFallbackChain(
+ ctx context.Context,
+ result *NotificationResult,
+ recipient, subject, htmlBody, textBody string,
+ data *NotificationData,
+ pmfErr error,
+) error {
+ relayAllowed := strings.TrimSpace(recipient) != "" && !isRootRecipient(recipient)
+ if relayAllowed {
+ e.logger.Info("Attempting relay fallback after PMF delivery failure...")
+ e.logger.Debug("Email fallback decision: stage=delivery reason=pmf_send_failed cause=%v", pmfErr)
+ relayErr := e.sendViaRelayFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "pmf_send_failed", pmfErr)
+ if relayErr == nil {
+ return nil
+ }
+ e.logger.Warning("WARNING: Relay fallback failed after PMF failure: %v", relayErr)
+ if !e.config.FallbackSendmail {
+ return fmt.Errorf("pmf failed: %w; relay fallback failed: %v", pmfErr, relayErr)
+ }
+ result.Metadata["relay_fallback_error"] = relayErr.Error()
+ } else {
+ if strings.TrimSpace(recipient) == "" {
+ e.logger.Info("Skipping relay fallback after PMF failure because no recipient is available")
+ } else {
+ e.logger.Info("Skipping relay fallback after PMF failure because relay blocks root recipients")
+ }
+ if !e.config.FallbackSendmail {
+ return pmfErr
+ }
+ }
+
+ if strings.TrimSpace(recipient) == "" {
+ return fmt.Errorf("pmf failed: %w; sendmail fallback unavailable because no valid recipient is configured", pmfErr)
+ }
+
+ e.logger.Info("Attempting sendmail fallback after PMF/relay delivery failure...")
+ e.logger.Debug("Email fallback decision: stage=delivery reason=pmf_or_relay_send_failed cause=%v", pmfErr)
+ return e.sendViaSendmailFallback(ctx, result, recipient, subject, htmlBody, textBody, data, "delivery", "pmf_or_relay_send_failed", pmfErr)
+}
+
func describeEmailMethod(method string) string {
switch method {
case "email-relay":
@@ -420,6 +479,10 @@ func describeEmailMethod(method string) string {
return "sendmail"
case "email-pmf":
return "proxmox-mail-forward"
+ case "email-relay-fallback":
+ return "cloud relay fallback"
+ case "email-sendmail-fallback":
+ return "sendmail fallback"
case "email-pmf-fallback":
return "proxmox-mail-forward fallback"
default:
@@ -1139,6 +1202,12 @@ func encodeQuotedPrintableBody(body string) string {
return encoded.String()
}
+func sanitizeHeaderValue(value string) string {
+ value = strings.ReplaceAll(value, "\r", "")
+ value = strings.ReplaceAll(value, "\n", "")
+ return strings.TrimSpace(value)
+}
+
func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody string, data *NotificationData) (emailMessage, toHeader string) {
e.logger.Debug("=== Building email message ===")
@@ -1147,12 +1216,16 @@ func (e *EmailNotifier) buildEmailMessage(recipient, subject, htmlBody, textBody
// Build email headers and body
var email strings.Builder
- toHeader = strings.TrimSpace(recipient)
+ toHeader = sanitizeHeaderValue(recipient)
if toHeader == "" {
toHeader = "root"
}
+ fromHeader := sanitizeHeaderValue(e.config.From)
+ if fromHeader == "" {
+ fromHeader = "no-reply@proxmox.tis24.it"
+ }
fmt.Fprintf(&email, "To: %s\n", toHeader)
- fmt.Fprintf(&email, "From: %s\n", e.config.From)
+ fmt.Fprintf(&email, "From: %s\n", fromHeader)
fmt.Fprintf(&email, "Subject: =?UTF-8?B?%s?=\n", encodedSubject)
email.WriteString("MIME-Version: 1.0\n")
diff --git a/internal/notify/email_delivery_methods_test.go b/internal/notify/email_delivery_methods_test.go
index d9807683..e3f0df22 100644
--- a/internal/notify/email_delivery_methods_test.go
+++ b/internal/notify/email_delivery_methods_test.go
@@ -128,7 +128,7 @@ func TestEmailNotifier_SendPMF_AllowsMissingRecipientAndInvokesForwarder(t *test
}
}
-func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) {
+func TestEmailNotifier_RelayFallback_UsesSendmail(t *testing.T) {
logger := logging.New(types.LogLevelDebug, false)
// Force relay failure.
@@ -140,21 +140,31 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) {
}))
defer server.Close()
- capturePath := filepath.Join(t.TempDir(), "pmf_capture.txt")
- t.Setenv("PMF_CAPTURE_PATH", capturePath)
+ dir := t.TempDir()
+ capturePath := filepath.Join(t.TempDir(), "sendmail_capture.txt")
+ t.Setenv("SENDMAIL_CAPTURE_PATH", capturePath)
- pmfScriptPath := writeCaptureScript(t, "proxmox-mail-forward", "PMF_CAPTURE_PATH")
+ sendmailPath := writeCmd(t, dir, "sendmail", `#!/bin/sh
+set -eu
+cat > "${SENDMAIL_CAPTURE_PATH}"
+echo "queued as FALLBACKQID"
+exit 0
+`)
+ writeCmd(t, dir, "mailq", "#!/bin/sh\necho \"Mail queue is empty\"\nexit 0\n")
+ writeCmd(t, dir, "tail", "#!/bin/sh\nexit 0\n")
+ writeCmd(t, dir, "journalctl", "#!/bin/sh\nexit 0\n")
+ writeCmd(t, dir, "systemctl", "#!/bin/sh\nexit 3\n")
origPath := os.Getenv("PATH")
- t.Setenv("PATH", filepath.Dir(pmfScriptPath)+string(os.PathListSeparator)+origPath)
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
- origCandidates := pmfLookPathCandidates
- pmfLookPathCandidates = []string{"proxmox-mail-forward"}
- t.Cleanup(func() { pmfLookPathCandidates = origCandidates })
+ origSendmailPath := sendmailBinaryPath
+ sendmailBinaryPath = sendmailPath
+ t.Cleanup(func() { sendmailBinaryPath = origSendmailPath })
notifier, err := NewEmailNotifier(EmailConfig{
Enabled: true,
DeliveryMethod: EmailDeliveryRelay,
- FallbackSendmail: true, // historical name; now means fallback to PMF
+ FallbackSendmail: true,
Recipient: "admin@example.com",
From: "no-reply@proxmox.example.com",
CloudRelayConfig: CloudRelayConfig{
@@ -175,13 +185,13 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) {
t.Fatalf("Send() returned unexpected error: %v", err)
}
if !result.Success {
- t.Fatalf("expected Success=true due to PMF fallback, got false (err=%v)", result.Error)
+ t.Fatalf("expected Success=true due to sendmail fallback, got false (err=%v)", result.Error)
}
if !result.UsedFallback {
t.Fatalf("expected UsedFallback=true")
}
- if result.Method != "email-pmf-fallback" {
- t.Fatalf("expected Method=email-pmf-fallback, got %q", result.Method)
+ if result.Method != "email-sendmail-fallback" {
+ t.Fatalf("expected Method=email-sendmail-fallback, got %q", result.Method)
}
if result.Error == nil {
t.Fatalf("expected original relay error preserved in result.Error")
@@ -193,34 +203,72 @@ func TestEmailNotifier_RelayFallback_UsesPMFOnly(t *testing.T) {
got, err := os.ReadFile(capturePath)
if err != nil {
- t.Fatalf("read pmf capture: %v", err)
+ t.Fatalf("read sendmail capture: %v", err)
}
if !strings.Contains(string(got), "To: admin@example.com\n") {
- t.Fatalf("expected To: admin@example.com header in PMF message")
+ t.Fatalf("expected To: admin@example.com header in sendmail message")
}
}
-func TestEmailNotifier_RelayFallback_UsesPMFWhenRecipientDetectionFails(t *testing.T) {
+func TestEmailNotifier_RelayFallback_DoesNotBypassMissingRecipient(t *testing.T) {
logger := logging.New(types.LogLevelDebug, false)
- capturePath := filepath.Join(t.TempDir(), "pmf_capture.txt")
- t.Setenv("PMF_CAPTURE_PATH", capturePath)
+ notifier, err := NewEmailNotifier(EmailConfig{
+ Enabled: true,
+ DeliveryMethod: EmailDeliveryRelay,
+ FallbackSendmail: true,
+ Recipient: "",
+ From: "no-reply@proxmox.example.com",
+ }, types.ProxmoxUnknown, logger)
+ if err != nil {
+ t.Fatalf("NewEmailNotifier() error = %v", err)
+ }
- pmfScriptPath := writeCaptureScript(t, "proxmox-mail-forward", "PMF_CAPTURE_PATH")
+ result, err := notifier.Send(context.Background(), createTestNotificationData())
+ if err != nil {
+ t.Fatalf("Send() returned unexpected error: %v", err)
+ }
+ if result.Success {
+ t.Fatalf("expected Success=false when relay and sendmail have no recipient")
+ }
+ if result.UsedFallback {
+ t.Fatalf("expected no fallback attempt without a recipient")
+ }
+ if result.Error == nil {
+ t.Fatalf("expected missing recipient error")
+ }
+}
+
+func TestEmailNotifier_RelayFallback_UsesSendmailWhenRootRecipientBlocked(t *testing.T) {
+ logger := logging.New(types.LogLevelDebug, false)
+
+ dir := t.TempDir()
+ capturePath := filepath.Join(t.TempDir(), "sendmail_capture.txt")
+ t.Setenv("SENDMAIL_CAPTURE_PATH", capturePath)
+
+ sendmailPath := writeCmd(t, dir, "sendmail", `#!/bin/sh
+set -eu
+cat > "${SENDMAIL_CAPTURE_PATH}"
+exit 0
+`)
+ writeCmd(t, dir, "mailq", "#!/bin/sh\necho \"Mail queue is empty\"\nexit 0\n")
+ writeCmd(t, dir, "tail", "#!/bin/sh\nexit 0\n")
+ writeCmd(t, dir, "journalctl", "#!/bin/sh\nexit 0\n")
+ writeCmd(t, dir, "systemctl", "#!/bin/sh\nexit 3\n")
origPath := os.Getenv("PATH")
- t.Setenv("PATH", filepath.Dir(pmfScriptPath)+string(os.PathListSeparator)+origPath)
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
- origCandidates := pmfLookPathCandidates
- pmfLookPathCandidates = []string{"proxmox-mail-forward"}
- t.Cleanup(func() { pmfLookPathCandidates = origCandidates })
+ origSendmailPath := sendmailBinaryPath
+ sendmailBinaryPath = sendmailPath
+ t.Cleanup(func() { sendmailBinaryPath = origSendmailPath })
notifier, err := NewEmailNotifier(EmailConfig{
Enabled: true,
DeliveryMethod: EmailDeliveryRelay,
FallbackSendmail: true,
- Recipient: "",
+ Recipient: "root@example.com",
From: "no-reply@proxmox.example.com",
- }, types.ProxmoxUnknown, logger)
+ }, types.ProxmoxBS, logger)
if err != nil {
t.Fatalf("NewEmailNotifier() error = %v", err)
}
@@ -230,19 +278,19 @@ func TestEmailNotifier_RelayFallback_UsesPMFWhenRecipientDetectionFails(t *testi
t.Fatalf("Send() returned unexpected error: %v", err)
}
if !result.Success {
- t.Fatalf("expected Success=true due to PMF preflight fallback, got false (err=%v)", result.Error)
+ t.Fatalf("expected Success=true due to sendmail preflight fallback, got false (err=%v)", result.Error)
}
if !result.UsedFallback {
t.Fatalf("expected UsedFallback=true")
}
- if result.Method != "email-pmf-fallback" {
- t.Fatalf("expected Method=email-pmf-fallback, got %q", result.Method)
+ if result.Method != "email-sendmail-fallback" {
+ t.Fatalf("expected Method=email-sendmail-fallback, got %q", result.Method)
}
if got, _ := result.Metadata["fallback_stage"].(string); got != "preflight" {
t.Fatalf("fallback_stage=%q want %q", got, "preflight")
}
- if got, _ := result.Metadata["fallback_reason"].(string); got != "recipient_autodetect_failed" {
- t.Fatalf("fallback_reason=%q want %q", got, "recipient_autodetect_failed")
+ if got, _ := result.Metadata["fallback_reason"].(string); got != "recipient_blocked_root" {
+ t.Fatalf("fallback_reason=%q want %q", got, "recipient_blocked_root")
}
if result.Error == nil {
t.Fatalf("expected original preflight cause preserved in result.Error")
@@ -250,34 +298,43 @@ func TestEmailNotifier_RelayFallback_UsesPMFWhenRecipientDetectionFails(t *testi
got, err := os.ReadFile(capturePath)
if err != nil {
- t.Fatalf("read pmf capture: %v", err)
+ t.Fatalf("read sendmail capture: %v", err)
}
msg := string(got)
- if !strings.Contains(msg, "To: root\n") {
- t.Fatalf("expected To: root header when recipient resolution fails, got:\n%s", msg)
+ if !strings.Contains(msg, "To: root@example.com\n") {
+ t.Fatalf("expected To: root@example.com header in sendmail message, got:\n%s", msg)
}
}
-func TestEmailNotifier_RelayFallback_UsesPMFWhenRootRecipientBlocked(t *testing.T) {
+func TestEmailNotifier_PMFFallback_UsesRelayFirst(t *testing.T) {
logger := logging.New(types.LogLevelDebug, false)
- capturePath := filepath.Join(t.TempDir(), "pmf_capture.txt")
- t.Setenv("PMF_CAPTURE_PATH", capturePath)
-
- pmfScriptPath := writeCaptureScript(t, "proxmox-mail-forward", "PMF_CAPTURE_PATH")
- origPath := os.Getenv("PATH")
- t.Setenv("PATH", filepath.Dir(pmfScriptPath)+string(os.PathListSeparator)+origPath)
-
origCandidates := pmfLookPathCandidates
- pmfLookPathCandidates = []string{"proxmox-mail-forward"}
+ pmfLookPathCandidates = []string{filepath.Join(t.TempDir(), "missing-proxmox-mail-forward")}
t.Cleanup(func() { pmfLookPathCandidates = origCandidates })
+ callCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ callCount++
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"success":true}`))
+ }))
+ defer server.Close()
+
notifier, err := NewEmailNotifier(EmailConfig{
Enabled: true,
- DeliveryMethod: EmailDeliveryRelay,
+ DeliveryMethod: EmailDeliveryPMF,
FallbackSendmail: true,
- Recipient: "root@example.com",
+ Recipient: "admin@example.com",
From: "no-reply@proxmox.example.com",
+ CloudRelayConfig: CloudRelayConfig{
+ WorkerURL: server.URL,
+ WorkerToken: "token",
+ HMACSecret: "secret",
+ Timeout: 5,
+ MaxRetries: 0,
+ RetryDelay: 0,
+ },
}, types.ProxmoxBS, logger)
if err != nil {
t.Fatalf("NewEmailNotifier() error = %v", err)
@@ -288,31 +345,97 @@ func TestEmailNotifier_RelayFallback_UsesPMFWhenRootRecipientBlocked(t *testing.
t.Fatalf("Send() returned unexpected error: %v", err)
}
if !result.Success {
- t.Fatalf("expected Success=true due to PMF preflight fallback, got false (err=%v)", result.Error)
+ t.Fatalf("expected Success=true due to relay fallback, got false (err=%v)", result.Error)
}
if !result.UsedFallback {
t.Fatalf("expected UsedFallback=true")
}
- if result.Method != "email-pmf-fallback" {
- t.Fatalf("expected Method=email-pmf-fallback, got %q", result.Method)
+ if result.Method != "email-relay-fallback" {
+ t.Fatalf("expected Method=email-relay-fallback, got %q", result.Method)
}
- if got, _ := result.Metadata["fallback_stage"].(string); got != "preflight" {
- t.Fatalf("fallback_stage=%q want %q", got, "preflight")
+ if result.Error == nil {
+ t.Fatalf("expected original PMF error preserved in result.Error")
}
- if got, _ := result.Metadata["fallback_reason"].(string); got != "recipient_blocked_root" {
- t.Fatalf("fallback_reason=%q want %q", got, "recipient_blocked_root")
+ if callCount != 1 {
+ t.Fatalf("expected relay fallback to be attempted once, got %d", callCount)
+ }
+}
+
+func TestEmailNotifier_PMFFallback_UsesSendmailAfterRelayFailure(t *testing.T) {
+ logger := logging.New(types.LogLevelDebug, false)
+
+ origCandidates := pmfLookPathCandidates
+ pmfLookPathCandidates = []string{filepath.Join(t.TempDir(), "missing-proxmox-mail-forward")}
+ t.Cleanup(func() { pmfLookPathCandidates = origCandidates })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":"temporary"}`))
+ }))
+ defer server.Close()
+
+ dir := t.TempDir()
+ capturePath := filepath.Join(t.TempDir(), "sendmail_capture.txt")
+ t.Setenv("SENDMAIL_CAPTURE_PATH", capturePath)
+
+ sendmailPath := writeCmd(t, dir, "sendmail", `#!/bin/sh
+set -eu
+cat > "${SENDMAIL_CAPTURE_PATH}"
+exit 0
+`)
+ writeCmd(t, dir, "mailq", "#!/bin/sh\necho \"Mail queue is empty\"\nexit 0\n")
+ writeCmd(t, dir, "tail", "#!/bin/sh\nexit 0\n")
+ writeCmd(t, dir, "journalctl", "#!/bin/sh\nexit 0\n")
+ writeCmd(t, dir, "systemctl", "#!/bin/sh\nexit 3\n")
+ origPath := os.Getenv("PATH")
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
+
+ origSendmailPath := sendmailBinaryPath
+ sendmailBinaryPath = sendmailPath
+ t.Cleanup(func() { sendmailBinaryPath = origSendmailPath })
+
+ notifier, err := NewEmailNotifier(EmailConfig{
+ Enabled: true,
+ DeliveryMethod: EmailDeliveryPMF,
+ FallbackSendmail: true,
+ Recipient: "admin@example.com",
+ From: "no-reply@proxmox.example.com",
+ CloudRelayConfig: CloudRelayConfig{
+ WorkerURL: server.URL,
+ WorkerToken: "token",
+ HMACSecret: "secret",
+ Timeout: 5,
+ MaxRetries: 0,
+ RetryDelay: 0,
+ },
+ }, types.ProxmoxBS, logger)
+ if err != nil {
+ t.Fatalf("NewEmailNotifier() error = %v", err)
+ }
+
+ result, err := notifier.Send(context.Background(), createTestNotificationData())
+ if err != nil {
+ t.Fatalf("Send() returned unexpected error: %v", err)
+ }
+ if !result.Success {
+ t.Fatalf("expected Success=true due to sendmail fallback, got false (err=%v)", result.Error)
+ }
+ if !result.UsedFallback {
+ t.Fatalf("expected UsedFallback=true")
+ }
+ if result.Method != "email-sendmail-fallback" {
+ t.Fatalf("expected Method=email-sendmail-fallback, got %q", result.Method)
}
if result.Error == nil {
- t.Fatalf("expected original preflight cause preserved in result.Error")
+ t.Fatalf("expected original PMF error preserved in result.Error")
}
got, err := os.ReadFile(capturePath)
if err != nil {
- t.Fatalf("read pmf capture: %v", err)
+ t.Fatalf("read sendmail capture: %v", err)
}
- msg := string(got)
- if !strings.Contains(msg, "To: root@example.com\n") {
- t.Fatalf("expected To: root@example.com header in PMF message, got:\n%s", msg)
+ if !strings.Contains(string(got), "To: admin@example.com\n") {
+ t.Fatalf("expected To: admin@example.com header in sendmail message")
}
}
@@ -414,6 +537,38 @@ func TestEmailNotifierBuildEmailMessage_EncodesUTF8BodiesAsSevenBitSafe(t *testi
}
}
+func TestEmailNotifierBuildEmailMessageSanitizesAddressHeaders(t *testing.T) {
+ logger := logging.New(types.LogLevelDebug, false)
+ logger.SetOutput(io.Discard)
+
+ notifier, err := NewEmailNotifier(EmailConfig{
+ Enabled: true,
+ DeliveryMethod: EmailDeliveryPMF,
+ From: "sender@example.com\r\nBcc: injected@example.com",
+ }, types.ProxmoxBS, logger)
+ if err != nil {
+ t.Fatalf("NewEmailNotifier() error = %v", err)
+ }
+
+ emailMessage, toHeader := notifier.buildEmailMessage(
+ "admin@example.com\r\nCc: injected@example.com",
+ "subject",
+ "html",
+ "text",
+ createTestNotificationData(),
+ )
+
+ if strings.Contains(emailMessage, "\r") {
+ t.Fatalf("email message contains raw carriage return:\n%s", emailMessage)
+ }
+ if strings.Contains(emailMessage, "\nCc: injected@example.com") || strings.Contains(emailMessage, "\nBcc: injected@example.com") {
+ t.Fatalf("email message contains injected header:\n%s", emailMessage)
+ }
+ if toHeader != "admin@example.comCc: injected@example.com" {
+ t.Fatalf("toHeader=%q", toHeader)
+ }
+}
+
func TestEmailNotifierIsMTAServiceActive_SystemctlMissing(t *testing.T) {
logger := logging.New(types.LogLevelDebug, false)
logger.SetOutput(io.Discard)
diff --git a/internal/notify/email_relay.go b/internal/notify/email_relay.go
index 26cd84c3..50278211 100644
--- a/internal/notify/email_relay.go
+++ b/internal/notify/email_relay.go
@@ -10,12 +10,15 @@ import (
"fmt"
"io"
"net/http"
+ "regexp"
"strings"
"time"
"github.com/tis24dev/proxsave/internal/logging"
)
+var relayScriptVersionPattern = regexp.MustCompile(`^v?([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?`)
+
// CloudRelayConfig holds configuration for email cloud relay
type CloudRelayConfig struct {
WorkerURL string
@@ -68,6 +71,7 @@ func sendViaCloudRelay(
if config.WorkerURL == "" {
config = DefaultCloudRelayConfig
}
+ payload.ScriptVersion = normalizeRelayScriptVersion(payload.ScriptVersion)
// Create HTTP client with timeout
client := &http.Client{
@@ -234,6 +238,24 @@ func sendViaCloudRelay(
return fmt.Errorf("cloud relay failed after %d attempts: %w", config.MaxRetries+1, lastErr)
}
+func normalizeRelayScriptVersion(version string) string {
+ version = strings.TrimSpace(version)
+ matches := relayScriptVersionPattern.FindStringSubmatch(version)
+ if matches == nil {
+ return "0.0.0"
+ }
+ major := matches[1]
+ minor := "0"
+ patch := "0"
+ if matches[2] != "" {
+ minor = matches[2]
+ }
+ if matches[3] != "" {
+ patch = matches[3]
+ }
+ return fmt.Sprintf("%s.%s.%s", major, minor, patch)
+}
+
// generateHMACSignature generates an HMAC-SHA256 signature for the payload
func generateHMACSignature(payload []byte, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
diff --git a/internal/notify/email_relay_test.go b/internal/notify/email_relay_test.go
index 99467e4f..39f676a5 100644
--- a/internal/notify/email_relay_test.go
+++ b/internal/notify/email_relay_test.go
@@ -30,6 +30,59 @@ func TestGenerateHMACSignature(t *testing.T) {
}
}
+func TestNormalizeRelayScriptVersion(t *testing.T) {
+ tests := map[string]string{
+ "": "0.0.0",
+ "dev": "0.0.0",
+ "v1.2.3": "1.2.3",
+ "1.2.3": "1.2.3",
+ "1.2": "1.2.0",
+ "0.0.0-dev": "0.0.0",
+ " 2.10.4+abc": "2.10.4",
+ }
+
+ for input, want := range tests {
+ if got := normalizeRelayScriptVersion(input); got != want {
+ t.Fatalf("normalizeRelayScriptVersion(%q)=%q, want %q", input, got, want)
+ }
+ }
+}
+
+func TestSendViaCloudRelay_NormalizesScriptVersionHeader(t *testing.T) {
+ var gotHeader string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotHeader = r.Header.Get("X-Script-Version")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"success":true}`))
+ }))
+ defer server.Close()
+
+ cfg := CloudRelayConfig{
+ WorkerURL: server.URL,
+ WorkerToken: "token",
+ HMACSecret: "secret",
+ Timeout: 5,
+ MaxRetries: 0,
+ RetryDelay: 0,
+ }
+ logger := logging.New(types.LogLevelDebug, false)
+ err := sendViaCloudRelay(context.Background(), cfg, EmailRelayPayload{
+ To: "dest@test.invalid",
+ Subject: "subject",
+ Report: map[string]interface{}{"ok": true},
+ Timestamp: time.Now().Unix(),
+ ServerMAC: "00:11:22:33:44:55",
+ ScriptVersion: "0.0.0-dev",
+ ServerID: "server-id",
+ }, logger)
+ if err != nil {
+ t.Fatalf("sendViaCloudRelay() error = %v", err)
+ }
+ if gotHeader != "0.0.0" {
+ t.Fatalf("X-Script-Version=%q, want 0.0.0", gotHeader)
+ }
+}
+
func TestIsQuotaLimit(t *testing.T) {
cases := []struct {
input string
diff --git a/internal/notify/email_test.go b/internal/notify/email_test.go
index cbc004b6..476f74cd 100644
--- a/internal/notify/email_test.go
+++ b/internal/notify/email_test.go
@@ -87,6 +87,8 @@ func TestDescribeEmailMethod(t *testing.T) {
{"email-relay", "cloud relay"},
{"email-sendmail", "sendmail"},
{"email-pmf", "proxmox-mail-forward"},
+ {"email-relay-fallback", "cloud relay fallback"},
+ {"email-sendmail-fallback", "sendmail fallback"},
{"email-pmf-fallback", "proxmox-mail-forward fallback"},
{"custom", "custom"},
}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index 8dd26022..94503eba 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -189,7 +189,7 @@ type StorageStatus struct {
type NotificationResult struct {
Success bool
UsedFallback bool // True if fallback method was used after primary failed
- Method string // "telegram", "email-relay", "email-sendmail"
+ Method string // "telegram", "email-relay", "email-pmf", "email-sendmail", or "*-fallback"
Error error // Original error (even if fallback succeeded)
Duration time.Duration
Metadata map[string]interface{} // Additional info (HTTP status, etc.)
diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go
index 7daf494f..92aba96d 100644
--- a/internal/notify/notify_test.go
+++ b/internal/notify/notify_test.go
@@ -134,6 +134,44 @@ func TestTemplateHelpers(t *testing.T) {
}
}
+func TestBuildEmailHTMLEscapesHeaderStorageAndFooterValues(t *testing.T) {
+ data := createTestNotificationData()
+ data.Hostname = ``
+ data.LocalStatusSummary = ``
+ data.LocalUsed = `14 GB`
+ data.LocalFree = `12 GB`
+ data.LocalPercent = `53%`
+ data.SecondaryStatusSummary = ``
+ data.SecondaryUsed = `secondary used`
+ data.SecondaryFree = `secondary free`
+ data.SecondaryPercent = `secondary percent`
+ data.CloudStatusSummary = ``
+ data.ScriptVersion = ``
+
+ html := BuildEmailHTML(data)
+ rawValues := []string{
+ data.Hostname,
+ data.LocalStatusSummary,
+ data.LocalUsed,
+ data.LocalFree,
+ data.LocalPercent,
+ data.SecondaryStatusSummary,
+ data.SecondaryUsed,
+ data.SecondaryFree,
+ data.SecondaryPercent,
+ data.CloudStatusSummary,
+ data.ScriptVersion,
+ }
+ for _, raw := range rawValues {
+ if strings.Contains(html, raw) {
+ t.Fatalf("BuildEmailHTML emitted raw value %q\nHTML:\n%s", raw, html)
+ }
+ if !strings.Contains(html, escapeHTML(raw)) {
+ t.Fatalf("BuildEmailHTML missing escaped value %q", escapeHTML(raw))
+ }
+ }
+}
+
func TestValueHelpers(t *testing.T) {
if got := valueOrNA(" "); got != "N/A" {
t.Fatalf("valueOrNA blank = %s, want N/A", got)
diff --git a/internal/notify/templates.go b/internal/notify/templates.go
index 0bdbfb14..ef44ff3f 100644
--- a/internal/notify/templates.go
+++ b/internal/notify/templates.go
@@ -77,6 +77,21 @@ func BuildEmailHTML(data *NotificationData) string {
statusColor := getStatusColor(data.Status)
statusText := strings.ToUpper(data.Status.String())
proxmoxType := strings.ToUpper(data.ProxmoxType.String())
+ backupDate := escapeHTML(data.BackupDate.Format("2006-01-02 15:04:05"))
+ hostname := escapeHTML(data.Hostname)
+ localEmoji := escapeHTML(GetStorageEmoji(data.LocalStatus))
+ localStatusSummary := escapeHTML(data.LocalStatusSummary)
+ localUsed := escapeHTML(data.LocalUsed)
+ localFree := escapeHTML(data.LocalFree)
+ localPercent := escapeHTML(data.LocalPercent)
+ secondaryEmoji := escapeHTML(GetStorageEmoji(data.SecondaryStatus))
+ secondaryStatusSummary := escapeHTML(data.SecondaryStatusSummary)
+ secondaryUsed := escapeHTML(data.SecondaryUsed)
+ secondaryFree := escapeHTML(data.SecondaryFree)
+ secondaryPercent := escapeHTML(data.SecondaryPercent)
+ cloudEmoji := escapeHTML(GetStorageEmoji(data.CloudStatus))
+ cloudStatusSummary := escapeHTML(data.CloudStatusSummary)
+ scriptVersion := escapeHTML(data.ScriptVersion)
// Determine backup paths sidebar color
backupPathsColor := "#4CAF50" // Green by default
@@ -113,7 +128,7 @@ func BuildEmailHTML(data *NotificationData) string {
// Header
fmt.Fprintf(&html, "
%s - %s
\n", data.Hostname, data.BackupDate.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&html, "%s - %s
\n", hostname, backupDate) html.WriteString("