package cli import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "sync" "syscall" "postern/internal/db" "postern/internal/dkim" "postern/internal/persistence" "github.com/spf13/cobra" ) type Service interface { Start() error Stop() error Status() string } // BootstrapDeps holds the resources created by the Bootstrap closure, which // are only required when running the "serve" command. type BootstrapDeps struct { DB *db.DB Persistence *persistence.Persistence DKIM *dkim.DKIM Services map[string]Service } type Config struct { // SocketPath is always required; non-serve commands use it to reach the // running daemon over a unix socket. SocketPath string // Bootstrap is invoked lazily by the "serve" command to create the DB, // persistence, DKIM, and network services. It may be nil when deps are // supplied directly (e.g. in tests). Bootstrap func() (*BootstrapDeps, error) // Optional pre-populated dependencies (for tests / direct usage). DB *db.DB Persistence *persistence.Persistence DKIM *dkim.DKIM Services map[string]Service } type Command struct { db *db.DB persistence *persistence.Persistence dkim *dkim.DKIM services map[string]Service socketPath string httpClient *http.Client bootstrap func() (*BootstrapDeps, error) } func New(config *Config) (*Command, error) { return &Command{ db: config.DB, persistence: config.Persistence, dkim: config.DKIM, services: config.Services, socketPath: config.SocketPath, httpClient: newUnixHTTPClient(config.SocketPath), bootstrap: config.Bootstrap, }, nil } func (c *Command) rootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "postern", Short: "Modern mail management", } serveCmd := &cobra.Command{ Use: "serve", Short: "Start the mail server", RunE: c.startServer, } pruneCmd := &cobra.Command{ Use: "prune", Short: "Delete orphaned mails", RunE: c.pruneMails, } userCmd := &cobra.Command{ Use: "user", Short: "Manage local login accounts", } userListCmd := &cobra.Command{ Use: "list", Short: "List all users", RunE: c.listUsers, } addUserCmd := &cobra.Command{ Use: "add ", Short: "Add a user", Args: cobra.ExactArgs(1), RunE: c.addUser, } addUserAddressCmd := &cobra.Command{ Use: "add-address
", Short: "Add an address for a user", Args: cobra.ExactArgs(2), RunE: c.addUserAddress, } userRemoveCmd := &cobra.Command{ Use: "remove ", Short: "Remove a user", Args: cobra.ExactArgs(1), RunE: c.removeUser, } userCmd.AddCommand(userListCmd) userCmd.AddCommand(addUserCmd) userCmd.AddCommand(addUserAddressCmd) userCmd.AddCommand(userRemoveCmd) backupMxCmd := &cobra.Command{ Use: "backup-mx", Short: "Manage inbound SMTP authentication", } backupMxListCmd := &cobra.Command{ Use: "list", Short: "List all SMTP auth users", Args: cobra.NoArgs, RunE: c.listSMTPAuth, } backupMxAddCmd := &cobra.Command{ Use: "add ", Short: "Add SMTP auth user", Args: cobra.ExactArgs(1), RunE: c.addSMTPAuth, } backupMxRemoveCmd := &cobra.Command{ Use: "remove ", Short: "Remove SMTP auth user", Args: cobra.ExactArgs(1), RunE: c.removeSMTPAuth, } backupMxCmd.AddCommand(backupMxListCmd) backupMxCmd.AddCommand(backupMxAddCmd) backupMxCmd.AddCommand(backupMxRemoveCmd) dkimCmd := &cobra.Command{ Use: "dkim", Short: "Manage DKIM selectors", } dkimListCmd := &cobra.Command{ Use: "list", Short: "List all available selectors", Args: cobra.NoArgs, RunE: c.listDKIM, } addDKIMCmd := &cobra.Command{ Use: "add [selector] [domain]", Short: "Add DKIM selector for domain", Args: cobra.ExactArgs(2), RunE: c.addDKIM, } getDKIMCmd := &cobra.Command{ Use: "get [selector] [domain]", Short: "Get public DKIM record for the given selector and domain", Args: cobra.ExactArgs(2), RunE: c.getDKIM, } enableDKIMCmd := &cobra.Command{ Use: "enable [selector] [domain]", Short: "Enable DKIM selector", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return c.setDKIMEnabled(cmd, args, true) }, } enableDKIMCmd.Flags().BoolP("force", "f", false, "Force enable DKIM even if validation fails") disableDKIMCmd := &cobra.Command{ Use: "disable [selector] [domain]", Short: "Disable DKIM selector", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return c.setDKIMEnabled(cmd, args, false) }, } dkimRemoveCmd := &cobra.Command{ Use: "remove [selector] [domain]", Short: "Remove DKIM selector", Args: cobra.ExactArgs(2), RunE: c.removeDKIM, } dkimCmd.AddCommand(dkimListCmd) dkimCmd.AddCommand(addDKIMCmd) dkimCmd.AddCommand(getDKIMCmd) dkimCmd.AddCommand(enableDKIMCmd) dkimCmd.AddCommand(disableDKIMCmd) dkimCmd.AddCommand(dkimRemoveCmd) kvCmd := &cobra.Command{ Use: "kv", Short: "Inspect the key-value store", } kvListCmd := &cobra.Command{ Use: "list", Short: "List all keys", Args: cobra.NoArgs, RunE: c.kvList, } kvGetCmd := &cobra.Command{ Use: "get ", Short: "Get the value for a key", Args: cobra.ExactArgs(1), RunE: c.kvGet, } kvPopCmd := &cobra.Command{ Use: "pop ", Short: "Remove key and return its value", Args: cobra.ExactArgs(1), RunE: c.kvPop, } kvCmd.AddCommand(kvListCmd) kvCmd.AddCommand(kvGetCmd) kvCmd.AddCommand(kvPopCmd) rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(pruneCmd) rootCmd.AddCommand(userCmd) rootCmd.AddCommand(backupMxCmd) rootCmd.AddCommand(dkimCmd) rootCmd.AddCommand(kvCmd) return rootCmd } func (c *Command) Execute() error { return c.rootCmd().Execute() } func (c *Command) startServer(_ *cobra.Command, _ []string) error { // Lazy bootstrap: only initialize resources when serve command is run if c.bootstrap != nil { deps, err := c.bootstrap() if err != nil { return err } c.db = deps.DB c.persistence = deps.Persistence c.dkim = deps.DKIM c.services = deps.Services } fmt.Println("Starting postern server...") slog.SetLogLoggerLevel(slog.LevelDebug) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // The admin socket always runs and can't be configured in the policy.star c.services["http"] = NewHTTPService(c.socketPath, c) var wg sync.WaitGroup for name, svc := range c.services { wg.Go(func() { err := svc.Start() if err != nil { slog.Error("running service", "name", name, "error", err) } }) } go func() { <-ctx.Done() slog.Info("shutdown signal received, stopping services") for name, svc := range c.services { if err := svc.Stop(); err != nil { slog.Error("stopping service", "name", name, "error", err) } } }() wg.Wait() // wait for services if c.db != nil { err := c.db.Close() if err != nil { slog.Error("closing database connection", "error", err) } slog.Info("closing database connection") } return nil }