2026-07-20 15:04:04 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-07-20 16:14:19 +00:00
|
|
|
_ "embed"
|
2026-07-20 15:14:52 +00:00
|
|
|
"errors"
|
|
|
|
|
"flag"
|
|
|
|
|
"fmt"
|
2026-07-20 15:04:04 +00:00
|
|
|
"log/slog"
|
|
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
2026-07-20 16:14:19 +00:00
|
|
|
"strings"
|
2026-07-20 15:04:04 +00:00
|
|
|
"sync"
|
|
|
|
|
"syscall"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-20 16:14:19 +00:00
|
|
|
//go:embed VERSION
|
|
|
|
|
var versionRaw string
|
|
|
|
|
|
|
|
|
|
var version = strings.TrimSpace(versionRaw)
|
|
|
|
|
|
2026-07-20 15:14:52 +00:00
|
|
|
func main() {
|
2026-07-20 16:14:19 +00:00
|
|
|
showVersion := flag.Bool("version", false, "print version and exit")
|
2026-07-20 15:14:52 +00:00
|
|
|
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")
|
2026-07-20 16:14:19 +00:00
|
|
|
mbsyncAccount := flag.String("mbsync-account", "", "mbsync channel/group name (defaults to account name)")
|
2026-07-20 15:14:52 +00:00
|
|
|
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")
|
2026-07-20 15:04:04 +00:00
|
|
|
|
2026-07-20 15:14:52 +00:00
|
|
|
flag.Parse()
|
|
|
|
|
|
2026-07-20 16:14:19 +00:00
|
|
|
if *showVersion {
|
|
|
|
|
fmt.Println(version)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 15:14:52 +00:00
|
|
|
// 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)
|
2026-07-20 15:04:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-20 15:14:52 +00:00
|
|
|
// CLI account flags override / replace config file accounts.
|
|
|
|
|
if *name != "" {
|
|
|
|
|
cfg.Accounts = []AccountConfig{{
|
|
|
|
|
Name: *name,
|
|
|
|
|
MaildirPath: *maildirPath,
|
2026-07-20 16:14:19 +00:00
|
|
|
MbsyncAccount: *mbsyncAccount,
|
2026-07-20 15:14:52 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 15:04:04 +00:00
|
|
|
|
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
|
defer stop()
|
|
|
|
|
|
|
|
|
|
var wg sync.WaitGroup
|
2026-07-20 15:14:52 +00:00
|
|
|
for _, a := range cfg.Accounts {
|
2026-07-20 15:04:04 +00:00
|
|
|
wg.Add(1)
|
2026-07-20 15:14:52 +00:00
|
|
|
go func(a AccountConfig) {
|
2026-07-20 15:04:04 +00:00
|
|
|
defer wg.Done()
|
|
|
|
|
watchWithRetry(ctx, a)
|
|
|
|
|
}(a)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<-ctx.Done()
|
|
|
|
|
wg.Wait()
|
|
|
|
|
slog.Info("shutdown complete")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 15:14:52 +00:00
|
|
|
func watchWithRetry(ctx context.Context, a AccountConfig) {
|
2026-07-20 15:04:04 +00:00
|
|
|
for {
|
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := watch(ctx, a); err != nil {
|
2026-07-20 15:14:52 +00:00
|
|
|
slog.Error("watch error", "account", a.Name, "err", err)
|
2026-07-20 15:04:04 +00:00
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-time.After(10 * time.Second):
|
2026-07-20 15:14:52 +00:00
|
|
|
slog.Info("reconnecting", "account", a.Name)
|
2026-07-20 15:04:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 15:14:52 +00:00
|
|
|
|