package policy import ( "context" "crypto/sha256" "fmt" "log" "log/slog" "net/netip" "os" "sync/atomic" "time" "postern/internal/db" "go.starlark.net/lib/json" sltime "go.starlark.net/lib/time" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" "go.starlark.net/syntax" ) type RelayConfig struct { Host string Port int Username string Password string } // engineState holds the reloadable, per-load Starlark environment for a // single evaluation of policy.star. The active snapshot is swapped in // atomically so in-flight hook calls keep a consistent view. type engineState struct { globals starlark.StringDict thread *starlark.Thread } type Engine struct { Listeners []ListenerConfig policyFile string database *db.DB state atomic.Pointer[engineState] } type ListenerConfig struct { Address netip.AddrPort Role string TLSCertFile string TLSKeyFile string } func NewEngine(policyFile string, database *db.DB) (*Engine, error) { e := &Engine{ policyFile: policyFile, database: database, } state, listeners, err := e.load() if err != nil { return nil, err } e.Listeners = listeners e.state.Store(state) return e, nil } // load reads and evaluates policyFile, returning a fresh engineState along // with the parsed listeners produced by this evaluation. func (e *Engine) load() (*engineState, []ListenerConfig, error) { script, err := os.ReadFile(e.policyFile) if err != nil { log.Fatal(err) } // Create an 'os' module containing our getenv function osModule := &starlarkstruct.Module{ Name: "os", Members: starlark.StringDict{ "getenv": starlark.NewBuiltin("getenv", starlarkGetenv), }, } // Expose the "os", "mda", "users", and "http" modules to the Starlark global environment m := &MDA{ parsedListeners: make([]ListenerConfig, 0), } predeclared := starlark.StringDict{ "exec": &ExecModule{}, "http": &HTTPModule{}, "json": json.Module, "kv": NewKVModule(e.database), "mda": m, "os": osModule, "time": sltime.Module, "users": NewUsersModule(e.database), } // Create a Starlark thread. We customize the Print function // so the Starlark `print()` built-in logs cleanly in Go. thread := &starlark.Thread{ Name: "mda-main", Print: func(_ *starlark.Thread, msg string) { fmt.Printf("[init.star]: %s\n", msg) }, } thread.SetLocal("context", context.Background()) // 1. Evaluate the script globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, "policy.star", script, predeclared) if err != nil { return nil, nil, err } // 2. Call the init() function initVal, ok := globals["init"] if !ok { return nil, nil, fmt.Errorf("init not found in policy") } _, err = starlark.Call(thread, initVal, nil, nil) if err != nil { return nil, nil, err } state := &engineState{ globals: globals, thread: thread, } return state, m.parsedListeners, nil } // reload re-evaluates policyFile and, on success, atomically swaps the new // engineState into the engine. On any error the previous state is kept // untouched so the last-known-good policy continues to serve. func (e *Engine) reload() error { state, _, err := e.load() if err != nil { return err } e.state.Store(state) return nil } // StartHotReload spawns a goroutine that polls the policy file's content hash // on the given interval and reloads whenever it changes. It is safe to call // once at startup; the goroutine exits when ctx is canceled. func (e *Engine) StartHotReload(ctx context.Context, interval time.Duration) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() var lastHash [32]byte if content, err := os.ReadFile(e.policyFile); err == nil { lastHash = sha256.Sum256(content) } for { select { case <-ctx.Done(): return case <-ticker.C: content, err := os.ReadFile(e.policyFile) if err != nil { slog.Error("policy file read failed", "file", e.policyFile, "err", err) continue } currentHash := sha256.Sum256(content) if currentHash == lastHash { continue } lastHash = currentHash slog.Info("policy file changed, reloading", "file", e.policyFile) err = e.reload() if err != nil { slog.Error("failed to reload", "file", e.policyFile, "err", err) } else { slog.Info("policy reloaded", "file", e.policyFile) } } } }() } func (e *Engine) OnMessageSubmit(user User, msg *Message) (RelayConfig, error) { s := e.state.Load() hookVal, ok := s.globals["on_message_submit"] if !ok { return RelayConfig{}, nil } result, err := starlark.Call(s.thread, hookVal, starlark.Tuple{user, msg}, nil) if err != nil { return RelayConfig{}, err } if result == starlark.None { return RelayConfig{}, nil } dict, ok := result.(*starlark.Dict) if !ok { return RelayConfig{}, fmt.Errorf("on_message_submit must return a dict, got %s", result.Type()) } relay := RelayConfig{ Host: dictString(dict, "host"), Port: dictInt(dict, "port"), Username: dictString(dict, "username"), Password: dictString(dict, "password"), } return relay, nil } func (e *Engine) OnMessageDeliver(conn Conn, user User, msg *Message) (string, error) { defaultMailbox := "INBOX" s := e.state.Load() hookVal, ok := s.globals["on_message_deliver"] if !ok { return defaultMailbox, nil } result, err := starlark.Call(s.thread, hookVal, starlark.Tuple{conn, user, msg}, nil) if err != nil { return defaultMailbox, err } mailbox := defaultMailbox if result != starlark.None { if s, ok := result.(starlark.String); ok { mailbox = string(s) } } return mailbox, nil } func (e *Engine) OnMessageMoved(user User, msg *Message, sourceFolder, destFolder string) error { s := e.state.Load() hookVal, ok := s.globals["on_message_moved"] if !ok { return nil } _, err := starlark.Call(s.thread, hookVal, starlark.Tuple{user, msg, starlark.String(sourceFolder), starlark.String(destFolder)}, nil) return err } // starlarkGetenv implements Python's os.getenv("KEY", "default") func starlarkGetenv(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key string var def starlark.Value = starlark.None if err := starlark.UnpackArgs(b.Name(), args, kwargs, "key", &key, "default?", &def); err != nil { return nil, err } if val, exists := os.LookupEnv(key); exists { return starlark.String(val), nil } return def, nil } func dictString(dict *starlark.Dict, key string) string { v, _, err := dict.Get(starlark.String(key)) if err != nil || v == starlark.None { return "" } if s, ok := v.(starlark.String); ok { return string(s) } return "" } func dictInt(dict *starlark.Dict, key string) int { v, _, err := dict.Get(starlark.String(key)) if err != nil || v == starlark.None { return 0 } if i, ok := v.(starlark.Int); ok { if n, ok := i.Int64(); ok { return int(n) } } return 0 }