From dc9c56dc2bdcb7854e99b1842bc7653cbbe0aa84 Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:35:17 +0300 Subject: [PATCH] feat(web): automation settings + in-app scheduler - Settings -> Automation: install/remove the OS job (same checks as 'eraser schedule install'), turn on the in-app scheduler, or run a cycle now. Shows mode, last run (from auto-state.json) and next run. Covers every profile. - schedule.enabled: a running 'eraser serve' runs 'eraser auto --once' as a child process every 6 hours while the OS job isn't installed, so every mode shares one cycle implementation, lock and state file. - 'Send all' refuses while a cycle is sending; the check and job start share a mutex with the scheduler so they can't pick the same brokers. - A daily-cap-paused job resumed at startup skips brokers sent since it paused, instead of emailing them again. - serve gets the -p shorthand the README already documented. --- README.md | 2 +- cmd/eraser/cmd_auto.go | 4 +- cmd/eraser/cmd_schedule.go | 57 ++---------- cmd/eraser/cmd_serve.go | 2 +- config.example.yaml | 7 ++ docs/architecture.md | 4 +- docs/commands.md | 3 + internal/config/config.go | 9 ++ internal/schedule/os.go | 81 +++++++++++++++-- internal/schedule/schedule_test.go | 14 +++ internal/web/handlers_jobs.go | 33 ++++++- internal/web/handlers_settings.go | 128 ++++++++++++++++++++++++++- internal/web/job.go | 12 +++ internal/web/scheduler.go | 104 ++++++++++++++++++++++ internal/web/scheduler_test.go | 114 ++++++++++++++++++++++++ internal/web/server.go | 23 +++++ internal/web/server_test.go | 6 ++ internal/web/templates/settings.html | 88 ++++++++++++++++++ 18 files changed, 626 insertions(+), 65 deletions(-) create mode 100644 internal/web/scheduler.go create mode 100644 internal/web/scheduler_test.go diff --git a/README.md b/README.md index 696942e..4e09d24 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ That's the password you'll use in Eraser's setup wizard. Your regular Gmail pass **Daily sending limits:** Gmail allows ~500 emails per day. Eraser caps itself at 450/day by default (`options.daily_send_limit`) and automatically resumes where it left off on the next run, so it's safe to just re-run `eraser send` until it reports nothing left to send. -**Automating it:** `eraser schedule install` has your OS run Eraser every 6 hours. Each run sends to whichever brokers are due (each broker is re-sent 25 days after its last request, within the daily cap) and checks your inbox for replies. Everything stays on your machine; `eraser schedule status` shows the last run, `eraser schedule remove` undoes it. Where there's no launchd/systemd, `eraser auto` does the same in the foreground. +**Automating it:** `eraser schedule install` has your OS run Eraser every 6 hours. Each run sends to whichever brokers are due (each broker is re-sent 25 days after its last request, within the daily cap) and checks your inbox for replies. Everything stays on your machine; `eraser schedule status` shows the last run, `eraser schedule remove` undoes it. The web UI's **Settings → Automation** card does all of this with buttons, including a "run while this app is open" option for systems without launchd/systemd; on the CLI, `eraser auto` loops in the foreground instead. ### Prefer not to give any tool your email password? diff --git a/cmd/eraser/cmd_auto.go b/cmd/eraser/cmd_auto.go index 1b3a79e..9d50086 100644 --- a/cmd/eraser/cmd_auto.go +++ b/cmd/eraser/cmd_auto.go @@ -48,6 +48,8 @@ progress exits quietly.`, mode := "once" if schedule.UnderOSJob() { mode = "os" + } else if os.Getenv("ERASER_AUTO_MODE") == "serve" { + mode = "serve" // started by the web UI's scheduler } return runAutoCycle(mode) } @@ -56,7 +58,7 @@ progress exits quietly.`, } cmd.Flags().BoolVar(&once, "once", false, "Run one cycle and exit") - cmd.Flags().DurationVar(&every, "every", 6*time.Hour, "Time between cycles in loop mode (minimum 1h)") + cmd.Flags().DurationVar(&every, "every", schedule.Interval, "Time between cycles in loop mode (minimum 1h)") return cmd } diff --git a/cmd/eraser/cmd_schedule.go b/cmd/eraser/cmd_schedule.go index 89bac9e..c2eab81 100644 --- a/cmd/eraser/cmd_schedule.go +++ b/cmd/eraser/cmd_schedule.go @@ -2,11 +2,8 @@ package main import ( "fmt" - "os" - "os/exec" "path/filepath" "runtime" - "strings" "time" "github.com/drumandbytes/eraser/internal/config" @@ -24,7 +21,8 @@ machine was asleep or off happen at the next wake. macOS uses a launchd agent (output in auto.log next to your config), Linux a systemd user timer (output in 'journalctl --user -u eraser-auto'). Elsewhere, -run 'eraser auto' in the foreground instead.`, +run 'eraser auto' in the foreground, or set schedule.enabled: true and keep +'eraser serve' running (Settings -> Automation in the web UI does both).`, } cmd.AddCommand(&cobra.Command{ Use: "install", @@ -58,35 +56,21 @@ run 'eraser auto' in the foreground instead.`, func runScheduleInstall() error { if !schedule.Supported() { - return fmt.Errorf("no OS scheduler support on %s - run 'eraser auto' in the foreground instead (it loops every 6h)", runtime.GOOS) + return fmt.Errorf("no OS scheduler support on %s - run 'eraser auto' in the foreground instead (it loops every 6h), or keep 'eraser serve' running with schedule.enabled: true", runtime.GOOS) } - cfgPath, err := filepath.Abs(resolveConfigPath()) - if err != nil { - return err - } - // Refuse a job that would fail on every run. - cfg, err := config.Load(cfgPath) + cfg, err := config.Load(resolveConfigPath()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } - if err := cfg.Validate(); err != nil { - return fmt.Errorf("fix your config before scheduling: %w", err) - } - if cfg.IsManualSend() && len(cfg.ConfiguredInboxes()) == 0 { - return fmt.Errorf("nothing to automate: send_mode is manual and no inbox is configured") - } - - exe, err := stableExecutable() + job, err := schedule.NewJob(cfg, resolveConfigPath()) if err != nil { return err } - - job := schedule.Job{Exe: exe, ConfigPath: cfgPath, LogPath: filepath.Join(filepath.Dir(cfgPath), "auto.log")} if err := schedule.Install(job); err != nil { return err } - fmt.Printf("✅ Scheduled: %s auto --once, %s.\n", exe, schedule.Every) + fmt.Printf("✅ Scheduled: %s auto --once, %s.\n", job.Exe, schedule.Every) if cfg.IsManualSend() { fmt.Println(" send_mode is manual, so runs only check the inbox.") } @@ -101,35 +85,6 @@ func runScheduleInstall() error { return nil } -// stableExecutable is the path the OS job should run. It prefers the eraser -// on PATH (e.g. Homebrew's symlink, which survives upgrades) when that's the -// same binary as this one, and refuses a 'go run' temp build. -func stableExecutable() (string, error) { - self, err := os.Executable() - if err != nil { - return "", fmt.Errorf("failed to find the eraser binary: %w", err) - } - selfReal, err := filepath.EvalSymlinks(self) - if err != nil { - selfReal = self - } - tmp, err := filepath.EvalSymlinks(os.TempDir()) - if err != nil { - tmp = os.TempDir() - } - if strings.HasPrefix(selfReal, filepath.Clean(tmp)+string(filepath.Separator)) || strings.Contains(selfReal, "go-build") { - return "", fmt.Errorf("this is a temporary 'go run' build (%s) - build or install eraser first, then run 'eraser schedule install' from that binary", selfReal) - } - if onPath, err := exec.LookPath("eraser"); err == nil { - if abs, err := filepath.Abs(onPath); err == nil { - if real, err := filepath.EvalSymlinks(abs); err == nil && real == selfReal { - return abs, nil - } - } - } - return selfReal, nil -} - func runScheduleStatus() error { dir := filepath.Dir(resolveConfigPath()) switch { diff --git a/cmd/eraser/cmd_serve.go b/cmd/eraser/cmd_serve.go index 7e46a39..5c6eae7 100644 --- a/cmd/eraser/cmd_serve.go +++ b/cmd/eraser/cmd_serve.go @@ -36,7 +36,7 @@ The server runs locally on your machine - no data is sent to external servers.`, }, } - cmd.Flags().IntVar(&port, "port", 8080, "Port to listen on") + cmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to listen on") return cmd } diff --git a/config.example.yaml b/config.example.yaml index 4eb5340..6c668e5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -101,3 +101,10 @@ options: # act on a request (this tool won't supply one on your behalf) # excluded_categories: # - requires-id + +# Automation (optional). The best option is 'eraser schedule install' (or +# Settings -> Automation -> "Schedule with my OS"), which runs Eraser every +# 6 hours even with the web UI closed. Where that isn't available, this makes +# a running 'eraser serve' do the same while it's open: +# schedule: +# enabled: true diff --git a/docs/architecture.md b/docs/architecture.md index d98b6dd..85c5276 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,7 @@ eraser/ │ ├── history/history.go # SQLite history tracking, pipeline status, per-profile scoping │ ├── inbox/ # IMAP monitoring + reply classification (success/form-required/ │ │ # confirmation/rejection/pending/bounced) +│ ├── schedule/ # unattended cycles: shared lock + state file, launchd/systemd install │ ├── template/ │ │ ├── template.go # Template rendering engine │ │ └── templates/ # Embedded: gdpr.tmpl, ccpa.tmpl, generic.tmpl @@ -42,7 +43,8 @@ eraser/ │ │ # by resource: handlers_pages.go (dashboard/brokers/history/ │ │ # pipeline/tasks), handlers_api.go (HTMX JSON/fragment │ │ # endpoints), handlers_jobs.go (send-job API + background -│ │ # send processing), handlers_settings.go, handlers_setup.go +│ │ # send processing), handlers_settings.go, handlers_setup.go; +│ │ # scheduler.go runs `eraser auto --once` for schedule.enabled │ │ # (setup wizard), handlers_profile.go (profile switching) │ ├── job.go # Job/JobManager - background send-job state, mutex-protected │ └── session.go # Setup-wizard session store diff --git a/docs/commands.md b/docs/commands.md index c85c01a..7ab005a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -40,6 +40,8 @@ The broker list is embedded in the binary. For the send-family commands (`send`, `auto` runs one cycle per call with `--once`, or loops every `--every` (min 1h) in the foreground. `schedule install` writes a launchd agent (`~/Library/LaunchAgents/com.drumandbytes.eraser.auto.plist`, output in `auto.log` next to the config) or a systemd user timer (`eraser-auto.timer`, output in the journal) that runs `auto --once --config ` at 00/06/12/18:07; missed slots run on wake. Every 6 hours rather than daily because `daily_send_limit` is a rolling 24h window: a run exactly 24h after the last one would find the cap still used up. All modes share `auto.lock` in the config directory, so cycles never overlap, and write the last result to `auto-state.json` (shown by `schedule status`). The loop refuses to start while the OS job is installed. With `send_mode: manual`, cycles only scan the inbox. +The web UI's Settings → Automation card does the same without a terminal: install/remove the OS job, turn on the in-app scheduler (`schedule.enabled`: `serve` runs `eraser auto --once` as a child process every 6 hours while it's open), or run a cycle now. It covers every profile, not just the active one. "Send all" refuses while a cycle holds the lock, and a daily-cap-paused job resumed at startup skips brokers sent since it paused. + Every command above (except `profile`, `add-broker`, `list-brokers`) accepts a global `--profile ` flag. It can be omitted entirely for the common single-profile setup; it's required once more than one profile is configured. See [multi-profile.md](multi-profile.md) for the full model. ## Configuration @@ -52,3 +54,4 @@ User config is stored at `~/.eraser/config.yaml` (see `config.example.yaml` for - `options` - `template`, `rate_limit_ms`, `daily_send_limit`, `broker_list` (`full`/`verified`), `broker_file` (path to your own list), `regions`, `excluded_brokers`, `excluded_categories` (skip every broker in a category, e.g. `requires-id`), `send_mode` (`manual` = Eraser never sends; render with `draft` / `send --manual`, record with `mark-sent`; no `email:` block needed) - `inbox` - IMAP settings, for `monitor`/`pipeline`/the web UI's inbox scan. Shared by default across every profile that doesn't set its own `mail.inbox` override (see [multi-profile.md](multi-profile.md#shared-inbox)); `monitor` scans every distinct inbox in one run, the web UI's scan/rescan only the active profile's own - `pipeline` - browser automation settings for `fill` +- `schedule` - `enabled: true` makes a running `eraser serve` run an automated cycle every 6 hours (the in-app fallback when the OS job from `schedule install` isn't set up; ignored while it is). Set from the web UI's Settings → Automation card too diff --git a/internal/config/config.go b/internal/config/config.go index 7fd86b3..69adff1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,15 @@ type Config struct { Options Options `yaml:"options"` Inbox InboxConfig `yaml:"inbox,omitempty"` Pipeline Pipeline `yaml:"pipeline,omitempty"` + Schedule Schedule `yaml:"schedule,omitempty"` +} + +// Schedule is the in-app fallback for automated cycles when the OS scheduler +// ('eraser schedule install') isn't set up. +type Schedule struct { + // Enabled makes a running 'eraser serve' run a cycle every 6 hours. + // Ignored while the OS job is installed. + Enabled bool `yaml:"enabled,omitempty"` } // NamedProfile is a person's identity plus the stable ID used by --profile, diff --git a/internal/schedule/os.go b/internal/schedule/os.go index c2a26eb..368b405 100644 --- a/internal/schedule/os.go +++ b/internal/schedule/os.go @@ -10,14 +10,36 @@ import ( "runtime" "strconv" "strings" + "time" + + "github.com/drumandbytes/eraser/internal/config" ) -// Every is how often the OS job runs. Six hours rather than daily: the send -// cap is a rolling 24h window, so a run exactly 24h after the last one still -// sees that run's sends inside the window and sends nothing. Runs with -// nothing due are cheap no-ops. +// Interval is how often cycles run, in every mode. Six hours rather than +// daily: the send cap is a rolling 24h window, so a run exactly 24h after +// the last one still sees that run's sends inside the window and sends +// nothing. Runs with nothing due are cheap no-ops. +const Interval = 6 * time.Hour + +// Every describes Interval for messages. const Every = "every 6 hours" +// osSlots are the local hours the OS job fires at, on minute 7. +var osSlots = []int{0, 6, 12, 18} + +// NextOSRun is when the installed OS job next fires after now. +func NextOSRun(now time.Time) time.Time { + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 7, 0, 0, now.Location()) + for d := 0; d < 2; d++ { + for _, h := range osSlots { + if t := day.AddDate(0, 0, d).Add(time.Duration(h) * time.Hour); t.After(now) { + return t + } + } + } + return day.AddDate(0, 0, 1) +} + const ( launchdLabel = "com.drumandbytes.eraser.auto" systemdUnit = "eraser-auto" @@ -40,6 +62,55 @@ func UnderOSJob() bool { return os.Getenv("XPC_SERVICE_NAME") == launchdLabel || os.Getenv("INVOCATION_ID") != "" } +// NewJob checks that cfg can run unattended and builds the OS job for it. +// It refuses configs that would fail on every run. +func NewJob(cfg *config.Config, configPath string) (Job, error) { + if err := cfg.Validate(); err != nil { + return Job{}, fmt.Errorf("fix your config before scheduling: %w", err) + } + if cfg.IsManualSend() && len(cfg.ConfiguredInboxes()) == 0 { + return Job{}, fmt.Errorf("nothing to automate: send_mode is manual and no inbox is configured") + } + abs, err := filepath.Abs(configPath) + if err != nil { + return Job{}, err + } + exe, err := StableExecutable() + if err != nil { + return Job{}, err + } + return Job{Exe: exe, ConfigPath: abs, LogPath: filepath.Join(filepath.Dir(abs), "auto.log")}, nil +} + +// StableExecutable is the path the OS job should run. It prefers the eraser +// on PATH (e.g. Homebrew's symlink, which survives upgrades) when that's the +// same binary as this one, and refuses a 'go run' temp build. +func StableExecutable() (string, error) { + self, err := os.Executable() + if err != nil { + return "", fmt.Errorf("failed to find the eraser binary: %w", err) + } + selfReal, err := filepath.EvalSymlinks(self) + if err != nil { + selfReal = self + } + tmp, err := filepath.EvalSymlinks(os.TempDir()) + if err != nil { + tmp = os.TempDir() + } + if strings.HasPrefix(selfReal, filepath.Clean(tmp)+string(filepath.Separator)) || strings.Contains(selfReal, "go-build") { + return "", fmt.Errorf("this is a temporary 'go run' build (%s) - build or install eraser first, then set up the schedule from that binary", selfReal) + } + if onPath, err := exec.LookPath("eraser"); err == nil { + if abs, err := filepath.Abs(onPath); err == nil { + if real, err := filepath.EvalSymlinks(abs); err == nil && real == selfReal { + return abs, nil + } + } + } + return selfReal, nil +} + // Supported reports whether Install can set up an OS job on this platform. func Supported() bool { return runtime.GOOS == "darwin" || runtime.GOOS == "linux" @@ -160,7 +231,7 @@ func renderPlist(j Job) string { fmt.Fprintf(&args, "\t\t%s\n", esc(a)) } var times strings.Builder - for _, h := range []int{0, 6, 12, 18} { + for _, h := range osSlots { fmt.Fprintf(×, "\t\tHour%dMinute7\n", h) } // StartCalendarInterval (unlike StartInterval) runs a missed slot on diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go index cfd62d5..76ee4e0 100644 --- a/internal/schedule/schedule_test.go +++ b/internal/schedule/schedule_test.go @@ -72,3 +72,17 @@ func TestRenderSystemd(t *testing.T) { } } } + +func TestNextOSRun(t *testing.T) { + loc := time.FixedZone("X", 2*3600) + for _, tc := range []struct{ now, want string }{ + {"2026-09-27 05:00", "2026-09-27 06:07"}, + {"2026-09-27 06:07", "2026-09-27 12:07"}, // exactly on a slot: the next one + {"2026-09-27 18:30", "2026-09-28 00:07"}, + } { + now, _ := time.ParseInLocation("2006-01-02 15:04", tc.now, loc) + if got := NextOSRun(now).Format("2006-01-02 15:04"); got != tc.want { + t.Errorf("NextOSRun(%s) = %s, want %s", tc.now, got, tc.want) + } + } +} diff --git a/internal/web/handlers_jobs.go b/internal/web/handlers_jobs.go index e51a968..c82d2cf 100644 --- a/internal/web/handlers_jobs.go +++ b/internal/web/handlers_jobs.go @@ -92,11 +92,22 @@ func (s *Server) resumePendingJob(state *PersistentJobState) { brokerMap[b.ID] = b } + // Anything sent since the job paused (an automatic run, the CLI, a + // "Send all" click) is no longer due; resending it would double-email. + var statuses map[string]history.BrokerStatus + if s.historyStore != nil { + statuses, _ = s.historyStore.GetAllBrokerStatuses(profileID) + } var toSend []BrokerWithStatus for _, id := range state.RemainingBrokers { - if b, ok := brokerMap[id]; ok { - toSend = append(toSend, BrokerWithStatus{Broker: b, Status: "never"}) + b, ok := brokerMap[id] + if !ok { + continue + } + if st, sent := statuses[id]; sent && st.Status == history.StatusSent && time.Since(st.LastSent) < history.ResendCooldown { + continue } + toSend = append(toSend, BrokerWithStatus{Broker: b, Status: "never"}) } if len(toSend) == 0 { @@ -320,7 +331,19 @@ func (s *Server) handleAPISendAll(w http.ResponseWriter, r *http.Request) { } // GetActive above is only a fast-fail; CreateIfNoActive re-checks under the lock + // An automatic cycle (in-app, CLI or OS job) sends to the same brokers; + // running both at once could email a broker twice. Checked under cycleMu + // so the in-app scheduler can't start one between this check and the + // job existing. + s.cycleMu.Lock() + if s.cycleRunning || s.cycleInProgress() { + s.cycleMu.Unlock() + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "An automatic run is sending right now. Try again once it finishes."}) + return + } job, created := s.jobManager.CreateIfNoActive(len(toSend), activeProfile.ID) + s.cycleMu.Unlock() if !created { w.WriteHeader(http.StatusConflict) _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -429,7 +452,11 @@ func (s *Server) processSendJob(job *Job, toSend []BrokerWithStatus, sender *ema // Check daily limit if alreadySentToday+sent >= dailyLimit { - job.Pause(sent, fmt.Sprintf("Daily limit of %d emails reached. Remaining %d brokers will be sent when you restart tomorrow.", dailyLimit, len(remaining))) + next := "Click Send all again tomorrow to send the rest, or turn on automation in Settings." + if s.inAppScheduling() || s.osInstalled() { + next = "Automation will send the rest once the limit frees up." + } + job.Pause(sent, fmt.Sprintf("Daily limit of %d emails reached. %d brokers remaining. %s", dailyLimit, len(remaining), next)) s.saveJobProgress(job, sent, failed, remaining) log.Printf("Job paused: daily limit of %d reached (%d already sent today, %d this run), %d remaining", dailyLimit, alreadySentToday, sent, len(remaining)) return diff --git a/internal/web/handlers_settings.go b/internal/web/handlers_settings.go index c1d225e..85b1b30 100644 --- a/internal/web/handlers_settings.go +++ b/internal/web/handlers_settings.go @@ -2,14 +2,137 @@ package web import ( "net/http" + "time" "github.com/drumandbytes/eraser/internal/config" + "github.com/drumandbytes/eraser/internal/schedule" ) func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ - "Title": "Settings", - "Config": s.getConfig(), + "Title": "Settings", + "Config": s.getConfig(), + "Automation": s.automationView(), + } + s.renderWithCSRF(w, r, "settings.html", data) +} + +// automationView is what the Settings "Automation" card shows. +type automationView struct { + Supported bool // OS scheduler available on this platform + Installed bool // OS job set up + Enabled bool // in-app scheduler turned on (schedule.enabled) + Running bool // a cycle holds the lock right now + Every string + Last *schedule.State + LastVia string + Next time.Time // zero when nothing is scheduled + Message string + Success bool +} + +func (s *Server) automationView() automationView { + v := automationView{ + Supported: schedule.Supported(), + Installed: s.osInstalled(), + Running: s.cycleInProgress(), + Every: schedule.Every, + } + if cfg := s.getConfig(); cfg != nil { + v.Enabled = cfg.Schedule.Enabled + } + if st, err := schedule.LoadState(s.dataDir); err == nil && st != nil { + v.Last = st + v.LastVia = map[string]string{"os": "OS scheduler", "serve": "this web app", "once": "eraser auto --once", "loop": "eraser auto"}[st.Mode] + if v.Enabled && !v.Installed { + v.Next = st.LastRun.Add(schedule.Interval) + } + } + if v.Installed { + v.Next = schedule.NextOSRun(time.Now()) + } + return v +} + +// handleSettingsAutomation handles the Automation card's buttons. These act +// on every profile, not just the active one: a cycle sends for all of them. +func (s *Server) handleSettingsAutomation(w http.ResponseWriter, r *http.Request) { + limitFormBody(w, r) + if err := r.ParseForm(); err != nil { + s.renderAutomationMessage(w, r, "Failed to parse form", false) + return + } + + switch r.FormValue("action") { + case "enable", "disable": + cfg := s.getConfig() + if cfg == nil { + s.renderAutomationMessage(w, r, "Finish setup before turning on automation.", false) + return + } + newCfg := *cfg + newCfg.Schedule.Enabled = r.FormValue("action") == "enable" + if err := config.Save(s.configPath, &newCfg); err != nil { + s.renderAutomationMessage(w, r, "Failed to save configuration: "+err.Error(), false) + return + } + s.config.Store(&newCfg) + if newCfg.Schedule.Enabled { + s.renderAutomationMessage(w, r, "Automation is on. Eraser runs "+schedule.Every+" while this web app is open.", true) + } else { + s.renderAutomationMessage(w, r, "In-app automation is off.", true) + } + + case "install": + cfg := s.getConfig() + if cfg == nil { + s.renderAutomationMessage(w, r, "Finish setup before scheduling.", false) + return + } + if !schedule.Supported() { + s.renderAutomationMessage(w, r, "Your OS has no supported scheduler. Use the in-app option instead.", false) + return + } + job, err := schedule.NewJob(cfg, s.configPath) + if err == nil { + err = s.installOS(job) + } + if err != nil { + s.renderAutomationMessage(w, r, "Couldn't install the scheduled job: "+err.Error(), false) + return + } + s.renderAutomationMessage(w, r, "Scheduled. Your OS now runs Eraser "+schedule.Every+", even when this web app is closed.", true) + + case "remove": + if err := s.removeOS(); err != nil { + s.renderAutomationMessage(w, r, "Couldn't remove the scheduled job: "+err.Error(), false) + return + } + s.renderAutomationMessage(w, r, "Removed the scheduled job.", true) + + case "run": + if s.getConfig() == nil { + s.renderAutomationMessage(w, r, "Finish setup first.", false) + return + } + if s.jobManager.AnyActive() || s.cycleInProgress() || !s.startCycle() { + s.renderAutomationMessage(w, r, "Something is already sending. Try again once it finishes.", false) + return + } + s.renderAutomationMessage(w, r, "Started a run. Refresh in a minute to see the result.", true) + + default: + s.renderAutomationMessage(w, r, "Unknown action", false) + } +} + +func (s *Server) renderAutomationMessage(w http.ResponseWriter, r *http.Request, message string, success bool) { + v := s.automationView() + v.Message, v.Success = message, success + data := map[string]interface{}{ + "Title": "Settings", + "Config": s.getConfig(), + "Automation": v, } s.renderWithCSRF(w, r, "settings.html", data) } @@ -74,6 +197,7 @@ func (s *Server) renderSettingsWithMessage(w http.ResponseWriter, r *http.Reques data := map[string]interface{}{ "Title": "Settings", "Config": s.getConfig(), + "Automation": s.automationView(), "InboxMessage": message, "InboxSuccess": success, } diff --git a/internal/web/job.go b/internal/web/job.go index b88d4b1..f8eabf4 100644 --- a/internal/web/job.go +++ b/internal/web/job.go @@ -282,6 +282,18 @@ func (jm *JobManager) GetActive(profileID string) *Job { return nil } +// AnyActive reports whether any profile has a send job running. +func (jm *JobManager) AnyActive() bool { + jm.mu.RLock() + defer jm.mu.RUnlock() + for _, job := range jm.jobs { + if job.GetStatus() == JobStatusRunning { + return true + } + } + return false +} + // Cleanup removes completed jobs older than the specified duration func (jm *JobManager) Cleanup(maxAge time.Duration) { jm.mu.Lock() diff --git a/internal/web/scheduler.go b/internal/web/scheduler.go new file mode 100644 index 0000000..63547de --- /dev/null +++ b/internal/web/scheduler.go @@ -0,0 +1,104 @@ +package web + +import ( + "context" + "log" + "os" + "os/exec" + "time" + + "github.com/drumandbytes/eraser/internal/schedule" +) + +// The in-app scheduler is the fallback for when the OS job isn't installed: +// while schedule.enabled is set, serve runs `eraser auto --once` every +// schedule.Interval. Running the CLI cycle as a child process (rather than a +// second implementation here) keeps one send/scan path, one lock and one +// state file for every mode. + +// runScheduler checks once a minute whether a cycle is due, until ctx ends. +func (s *Server) runScheduler(ctx context.Context) { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + if s.cycleDue() { + s.startCycle() + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// inAppScheduling reports whether serve is the one scheduling cycles. +func (s *Server) inAppScheduling() bool { + cfg := s.getConfig() + return cfg != nil && cfg.Schedule.Enabled && !s.osInstalled() +} + +func (s *Server) cycleDue() bool { + if !s.inAppScheduling() || s.jobManager.AnyActive() || s.cycleInProgress() { + return false + } + st, err := schedule.LoadState(s.dataDir) + if err != nil { + log.Printf("Warning: %v", err) + return false + } + // The state file is shared with the CLI and the OS job, so a recent + // cycle from any mode counts, and restarting serve doesn't trigger one. + return st == nil || time.Since(st.LastRun) >= schedule.Interval +} + +// startCycle launches one cycle in the background. It returns false if one +// started by this server is still running or a web send job is active. +// cycleMu makes that check and a "Send all" job start mutually exclusive +// (see handleAPISendAll), so the two can't pick the same brokers at once. +func (s *Server) startCycle() bool { + s.cycleMu.Lock() + if s.cycleRunning || s.jobManager.AnyActive() { + s.cycleMu.Unlock() + return false + } + s.cycleRunning = true + s.cycleMu.Unlock() + + go func() { + defer func() { + s.cycleMu.Lock() + s.cycleRunning = false + s.cycleMu.Unlock() + }() + exe, err := os.Executable() + if err != nil { + log.Printf("Automatic run: can't find the eraser binary: %v", err) + return + } + cmd := exec.Command(exe, "auto", "--once", "--config", s.configPath) + cmd.Env = append(os.Environ(), "ERASER_AUTO_MODE=serve") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + log.Printf("Automatic run starting") + if err := cmd.Run(); err != nil { + log.Printf("Automatic run finished with an error: %v", err) + return + } + log.Printf("Automatic run finished") + }() + return true +} + +// cycleInProgress reports whether any cycle (this server's, the CLI's or the +// OS job's) holds the shared lock right now. +func (s *Server) cycleInProgress() bool { + release, ok, err := schedule.TryLock(s.dataDir) + if err != nil { + return false + } + if ok { + release() + return false + } + return true +} diff --git a/internal/web/scheduler_test.go b/internal/web/scheduler_test.go new file mode 100644 index 0000000..4864cc8 --- /dev/null +++ b/internal/web/scheduler_test.go @@ -0,0 +1,114 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/drumandbytes/eraser/internal/broker" + "github.com/drumandbytes/eraser/internal/config" + "github.com/drumandbytes/eraser/internal/schedule" +) + +func TestCycleDue(t *testing.T) { + s := newTestServer(t, testConfig()) + if s.cycleDue() { + t.Fatal("due with schedule.enabled off") + } + + cfg := *s.getConfig() + cfg.Schedule.Enabled = true + s.config.Store(&cfg) + if !s.cycleDue() { + t.Fatal("not due when enabled and no cycle has ever run") + } + + if err := schedule.SaveState(s.dataDir, schedule.State{LastRun: time.Now().Add(-time.Hour), Mode: "os"}); err != nil { + t.Fatal(err) + } + if s.cycleDue() { + t.Fatal("due an hour after the last cycle") + } + if err := schedule.SaveState(s.dataDir, schedule.State{LastRun: time.Now().Add(-schedule.Interval - time.Minute)}); err != nil { + t.Fatal(err) + } + if !s.cycleDue() { + t.Fatal("not due after a full interval") + } + + s.osInstalled = func() bool { return true } + if s.cycleDue() { + t.Fatal("serve scheduled a cycle while the OS job is installed") + } +} + +func postAutomation(t *testing.T, s *Server, action string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/settings/automation", strings.NewReader(url.Values{"action": {action}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleSettingsAutomation(w, req) + if w.Code != http.StatusOK { + t.Fatalf("%s: status %d: %s", action, w.Code, w.Body.String()) + } + return w +} + +func TestSettingsAutomationToggleSavesConfig(t *testing.T) { + s := newTestServer(t, testConfig()) + s.configPath = filepath.Join(t.TempDir(), "config.yaml") + + postAutomation(t, s, "enable") + saved, err := config.Load(s.configPath) + if err != nil { + t.Fatal(err) + } + if !saved.Schedule.Enabled || !s.getConfig().Schedule.Enabled { + t.Fatal("enable didn't set schedule.enabled on disk and in the live config") + } + + postAutomation(t, s, "disable") + if s.getConfig().Schedule.Enabled { + t.Fatal("disable left schedule.enabled on") + } +} + +func TestSettingsAutomationInstallRefusesInvalidConfig(t *testing.T) { + if !schedule.Supported() { + t.Skip("no OS scheduler on this platform") + } + s := newTestServer(t, testConfig()) // no email: block, so Validate fails + s.configPath = filepath.Join(t.TempDir(), "config.yaml") + installed := false + s.installOS = func(schedule.Job) error { installed = true; return nil } + + w := postAutomation(t, s, "install") + if installed { + t.Fatal("installed an OS job for a config that fails validation") + } + if !strings.Contains(w.Body.String(), "Couldn't install") { + t.Fatalf("no error shown: %s", w.Body.String()) + } +} + +func TestSendAllRefusedWhileCycleRuns(t *testing.T) { + cfg := testConfig() + cfg.Email = config.EmailConfig{Provider: "smtp", From: "test@example.com", SMTP: config.SMTPConfig{Host: "smtp.example.com", Port: 465, Username: "u", Password: "p", UseTLS: true}} + s := newTestServer(t, cfg) + s.brokerDB = &broker.BrokerDatabase{Brokers: []broker.Broker{{ID: "acme", Name: "Acme", Email: "privacy@acme.example", Region: "eu"}}} + release, ok, err := schedule.TryLock(s.dataDir) + if err != nil || !ok { + t.Fatalf("TryLock: %v %v", ok, err) + } + defer release() + + w := httptest.NewRecorder() + s.handleAPISendAll(w, httptest.NewRequest(http.MethodPost, "/api/send-all", nil)) + if w.Code != http.StatusConflict { + t.Fatalf("status %d, want 409: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 4398d4c..a80ddb4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -22,6 +22,7 @@ import ( "github.com/drumandbytes/eraser/internal/broker" "github.com/drumandbytes/eraser/internal/config" "github.com/drumandbytes/eraser/internal/history" + "github.com/drumandbytes/eraser/internal/schedule" emaTemplate "github.com/drumandbytes/eraser/internal/template" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -119,6 +120,16 @@ type Server struct { rateLimiter *RateLimiter jobManager *JobManager jobPersistence *JobPersistence + dataDir string // config directory: job state, schedule lock and state + + // In-app scheduler (scheduler.go). The OS hooks are fields so tests + // don't touch the real launchd/systemd setup. + cycleMu sync.Mutex + cycleRunning bool + stopScheduler context.CancelFunc + osInstalled func() bool + installOS func(schedule.Job) error + removeOS func() error } func NewServer(port int, cfg *config.Config, configPath string, brokerDB *broker.BrokerDatabase, historyStore *history.Store, tmplEngine *emaTemplate.Engine) (*Server, error) { @@ -146,6 +157,10 @@ func NewServer(port int, cfg *config.Config, configPath string, brokerDB *broker rateLimiter: NewRateLimiter(defaultRateLimit, defaultRateWindow), jobManager: NewJobManager(), jobPersistence: NewJobPersistence(dataDir), + dataDir: dataDir, + osInstalled: schedule.Installed, + installOS: schedule.Install, + removeOS: schedule.Remove, } s.config.Store(cfg) @@ -317,6 +332,10 @@ func (s *Server) Start() error { // Check for pending job and offer to resume s.checkPendingJob() + ctx, cancel := context.WithCancel(context.Background()) + s.stopScheduler = cancel + go s.runScheduler(ctx) + go func() { time.Sleep(500 * time.Millisecond) url := fmt.Sprintf("http://localhost:%d", s.port) @@ -335,6 +354,9 @@ func (s *Server) Start() error { // Shutdown gracefully shuts down the server func (s *Server) Shutdown(ctx context.Context) error { + if s.stopScheduler != nil { + s.stopScheduler() + } return s.httpServer.Shutdown(ctx) } @@ -365,6 +387,7 @@ func (s *Server) setupRouter() *chi.Mux { r.Get("/history", s.handleHistory) r.Get("/settings", s.handleSettings) r.Post("/settings/inbox", s.handleSettingsInbox) + r.Post("/settings/automation", s.handleSettingsAutomation) r.Get("/settings/profiles/new", s.handleSettingsProfileNew) r.Post("/settings/profiles/new", s.handleSettingsProfileNew) r.Get("/settings/profiles/{profileID}/edit", s.handleSettingsProfileEdit) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 9a0ce23..50251b8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -11,6 +11,7 @@ import ( "github.com/drumandbytes/eraser/internal/broker" "github.com/drumandbytes/eraser/internal/config" + "github.com/drumandbytes/eraser/internal/schedule" emaTemplate "github.com/drumandbytes/eraser/internal/template" ) @@ -30,6 +31,11 @@ func newTestServer(t *testing.T, cfg *config.Config) *Server { if err != nil { t.Fatalf("NewServer: %v", err) } + // Keep the schedule lock/state and the OS scheduler off the real machine. + s.dataDir = t.TempDir() + s.osInstalled = func() bool { return false } + s.installOS = func(schedule.Job) error { t.Error("unexpected OS install"); return nil } + s.removeOS = func() error { t.Error("unexpected OS remove"); return nil } return s } diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 8d5a187..cafde82 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -200,6 +200,94 @@

Gmail Setup Instructions

{{end}} + + {{with .Automation}} +
+
+
+ + + +
+
+

Automation

+

Send due requests and check the inbox {{.Every}}, for every profile

+
+
+ +
+
+
+

Status

+

+ {{if .Installed}}Scheduled by your OS - runs even when this web app is closed + {{else if .Enabled}}On while this web app is running + {{else}}Off{{end}} +

+
+ {{if .Running}}Running now + {{else if or .Installed .Enabled}}Active + {{else}}Off{{end}} +
+
+
+

Last run

+ {{if .Last}} +

{{.Last.LastRun.Format "Jan 2, 15:04"}}{{if .LastVia}} via {{.LastVia}}{{end}} - {{.Last.Sent}} sent

+ {{if .Last.Error}}

{{.Last.Error}}

{{end}} + {{else}} +

Never

+ {{end}} +
+
+ {{if not .Next.IsZero}} +
+
+

Next run

+

Around {{.Next.Format "Jan 2, 15:04"}}

+
+
+ {{end}} +
+ +
+ {{if .Supported}} +
+ {{$.CSRFField}} + {{if .Installed}} + + {{else}} + + {{end}} +
+ {{end}} + {{if not .Installed}} +
+ {{$.CSRFField}} + {{if .Enabled}} + + {{else}} + + {{end}} +
+ {{end}} +
+ {{$.CSRFField}} + +
+
+

+ Each broker is re-sent 25 days after its last request, within your daily limit. {{if .Supported}}The OS schedule keeps working with this app closed; the in-app option only runs while it's open.{{else}}Your OS has no supported scheduler, so automation runs while this app is open (or run eraser auto in a terminal).{{end}} +

+ + {{if .Message}} +
+ {{.Message}} +
+ {{end}} +
+ {{end}} +