Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
4 changes: 3 additions & 1 deletion cmd/eraser/cmd_auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
}
Expand Down
57 changes: 6 additions & 51 deletions cmd/eraser/cmd_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@ package main

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/drumandbytes/eraser/internal/config"
Expand All @@ -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",
Expand Down Expand Up @@ -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.")
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/eraser/cmd_serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <abs path>` 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 <id>` 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
Expand All @@ -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
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 76 additions & 5 deletions internal/schedule/os.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -160,7 +231,7 @@ func renderPlist(j Job) string {
fmt.Fprintf(&args, "\t\t<string>%s</string>\n", esc(a))
}
var times strings.Builder
for _, h := range []int{0, 6, 12, 18} {
for _, h := range osSlots {
fmt.Fprintf(&times, "\t\t<dict><key>Hour</key><integer>%d</integer><key>Minute</key><integer>7</integer></dict>\n", h)
}
// StartCalendarInterval (unlike StartInterval) runs a missed slot on
Expand Down
14 changes: 14 additions & 0 deletions internal/schedule/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
33 changes: 30 additions & 3 deletions internal/web/handlers_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}{
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading