package main import ( "cmp" "crypto/tls" "fmt" "log" "log/slog" "os" "path" "path/filepath" "runtime" "postern/internal/cli" "postern/internal/db" "postern/internal/dkim" "postern/internal/imap" "postern/internal/inbound" "postern/internal/persistence" "postern/internal/policy" "postern/internal/submission" ) func main() { socketPath := resolveSocketPath() // Create a bootstrap closure that encapsulates all the resource initialization // that is only needed when the "serve" command is executed. bootstrap := func() (*cli.BootstrapDeps, error) { posternPersistenceDir := cmp.Or(os.Getenv("POSTERN_PERSISTENCE_DIR"), os.Getenv("STATE_DIRECTORY")) if posternPersistenceDir == "" { return nil, fmt.Errorf("POSTERN_PERSISTENCE_DIR or STATE_DIRECTORY is required") } err := persistence.CheckDirWritable(posternPersistenceDir) if err != nil { return nil, fmt.Errorf("POSTERN_PERSISTENCE_DIR: %w", err) } slog.Info("using postern persistence", "dir", posternPersistenceDir) persistenceMasterkey := os.Getenv("POSTERN_MASTERKEY_FILE") if persistenceMasterkey == "" { persistenceMasterkey = path.Join(os.Getenv("CREDENTIALS_DIRECTORY"), "masterkey") } p, err := persistence.New(persistence.Config{ BlobRoot: path.Join(posternPersistenceDir, "blobs"), MasterkeyFile: persistenceMasterkey, }) if err != nil { return nil, fmt.Errorf("failed to initialize persistence: %w", err) } posternDB, err := db.OpenDB(&db.Config{ DBPath: path.Join(posternPersistenceDir, "meta.db"), }) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } engine, err := policy.NewEngine(path.Join(os.Getenv("CONFIGURATION_DIRECTORY"), "policy.star"), posternDB) if err != nil { return nil, fmt.Errorf("failed to initialize policy engine: %w", err) } dkimSvc := dkim.New(&dkim.Config{ DB: posternDB, Persistence: p, }) listenAddrs := make(map[string]policy.ListenerConfig) for _, l := range engine.Listeners { listenAddrs[l.Role] = l } services := make(map[string]cli.Service) if listener, ok := listenAddrs["imap"]; ok { cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) if err != nil { return nil, fmt.Errorf("failed to load IMAP TLS cert: %w", err) } tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, } imapServer, err := imap.NewServer(&imap.Config{ DB: posternDB, Listen: listener.Address.String(), Persistence: p, TLSConfig: tlsConfig, PolicyEngine: &engine, }) if err != nil { return nil, fmt.Errorf("failed to create IMAP server: %w", err) } services["imap"] = imapServer } if listener, ok := listenAddrs["smtp_inbound"]; ok { cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) if err != nil { return nil, fmt.Errorf("failed to load SMTP inbound TLS cert: %w", err) } tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, } receptionServer, err := inbound.NewServer(&inbound.Config{ DB: posternDB, Deliverer: services["imap"].(inbound.MessageDeliverer), Listen: listener.Address.String(), TLS: tlsConfig, PolicyEngine: &engine, }) if err != nil { return nil, fmt.Errorf("failed to create SMTP inbound server: %w", err) } services["smtp_inbound"] = receptionServer } if listener, ok := listenAddrs["smtp_submission"]; ok { cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) if err != nil { return nil, fmt.Errorf("failed to load SMTP submission TLS cert: %w", err) } tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, } submissionServer, err := submission.NewServer(&submission.Config{ DB: posternDB, DKIM: dkimSvc, Listen: listener.Address.String(), TLS: tlsConfig, PolicyEngine: &engine, }) if err != nil { return nil, fmt.Errorf("failed to create SMTP submission server: %w", err) } services["smtp_submission"] = submissionServer } if listener, ok := listenAddrs["smtp_local_submission"]; ok { // Check that address is loopback. Other addresses are not allowed for security reasons. if !listener.Address.Addr().IsLoopback() { return nil, fmt.Errorf("smtp_local_submission address is not a loopback address") } submissionServer, err := submission.NewServer(&submission.Config{ DB: posternDB, DKIM: dkimSvc, Listen: listener.Address.String(), PolicyEngine: &engine, IsLocalSubmission: true, }) if err != nil { return nil, fmt.Errorf("failed to create local SMTP submission server: %w", err) } services["smtp_local_submission"] = submissionServer } return &cli.BootstrapDeps{ DB: posternDB, Persistence: p, DKIM: dkimSvc, Services: services, }, nil } posternCLI, err := cli.New(&cli.Config{ SocketPath: socketPath, Bootstrap: bootstrap, }) if err != nil { log.Fatal(err) } if err := posternCLI.Execute(); err != nil { os.Exit(1) } } // resolveSocketPath is runtime agnostic (Linux, MacOS) to choose the admin http socket path func resolveSocketPath() string { if p := os.Getenv("POSTERN_SOCKET"); p != "" { return p } if rd := os.Getenv("RUNTIME_DIRECTORY"); rd != "" { return filepath.Join(rd, "postern.sock") } if runtime.GOOS == "linux" { return "/run/postern/postern.sock" } if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, ".postern", "postern.sock") } return "/tmp/postern.sock" }