package main import ( "context" _ "embed" "errors" "flag" "fmt" "log/slog" "os" "os/signal" "strings" "sync" "syscall" "time" ) //go:embed VERSION var versionRaw string var version = strings.TrimSpace(versionRaw) func main() { showVersion := flag.Bool("version", false, "print version and exit") configPath := flag.String("config", defaultConfigPath(), "path to config file") logLevel := flag.String("log-level", "", "log level: debug, info, warn, error") // Single-account flags — when --name is set, these define the account // directly without requiring a config file. name := flag.String("name", "", "account name") maildirPath := flag.String("maildir-path", "", "path to the account's maildir") mbsyncAccount := flag.String("mbsync-account", "", "mbsync channel/group name (defaults to account name)") passwordCommand := flag.String("password-command", "", "shell command that prints the JMAP bearer token") tokenFile := flag.String("token-file", "", "path to file containing the JMAP bearer token") tokenEnv := flag.String("token-env", "", "environment variable containing the JMAP bearer token") flag.Parse() if *showVersion { fmt.Println(version) return } // Load config file if it exists; a missing default path is not an error. var cfg Config if data, err := os.ReadFile(*configPath); err == nil { if err := (&cfg).unmarshal(data); err != nil { fmt.Fprintf(os.Stderr, "config: %v\n", err) os.Exit(1) } } else if *configPath != defaultConfigPath() || !errors.Is(err, os.ErrNotExist) { fmt.Fprintf(os.Stderr, "config: %v\n", err) os.Exit(1) } // CLI account flags override / replace config file accounts. if *name != "" { cfg.Accounts = []AccountConfig{{ Name: *name, MaildirPath: *maildirPath, MbsyncAccount: *mbsyncAccount, PasswordCommand: *passwordCommand, TokenFile: *tokenFile, TokenEnv: *tokenEnv, }} } // CLI log level overrides config file. if *logLevel != "" { cfg.LogLevel = *logLevel } if cfg.LogLevel == "" { cfg.LogLevel = "info" } var level slog.Level if err := level.UnmarshalText([]byte(cfg.LogLevel)); err != nil { fmt.Fprintf(os.Stderr, "invalid log level %q: %v\n", cfg.LogLevel, err) os.Exit(1) } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) if len(cfg.Accounts) == 0 { fmt.Fprintln(os.Stderr, "no accounts configured — provide a config file or use --name with token flags") os.Exit(1) } for _, a := range cfg.Accounts { if err := a.validate(); err != nil { fmt.Fprintf(os.Stderr, "config: %v\n", err) os.Exit(1) } } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() var wg sync.WaitGroup for _, a := range cfg.Accounts { wg.Add(1) go func(a AccountConfig) { defer wg.Done() watchWithRetry(ctx, a) }(a) } <-ctx.Done() wg.Wait() slog.Info("shutdown complete") } func watchWithRetry(ctx context.Context, a AccountConfig) { for { if ctx.Err() != nil { return } if err := watch(ctx, a); err != nil { slog.Error("watch error", "account", a.Name, "err", err) } select { case <-ctx.Done(): return case <-time.After(10 * time.Second): slog.Info("reconnecting", "account", a.Name) } } }