mbrts/main.go
2026-07-20 10:42:40 -06:00

127 lines
3.2 KiB
Go

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)
}
}
}