// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd // Command nuntius runs the contact form backend server. // // Configuration is loaded from a TOML file (default: /etc/nuntius/config.toml). // The TOML file may reference environment variables for secrets using // ${VAR_NAME} or $VAR_NAME syntax. package main import ( "context" "errors" "flag" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "strconv" "syscall" "time" "sourcedock.dev/petrbalvin/nuntius/internal/config" "sourcedock.dev/petrbalvin/nuntius/internal/handler" "sourcedock.dev/petrbalvin/nuntius/internal/version" ) func main() { showVersion := flag.Bool("version", false, "Print version and exit") checkConfig := flag.Bool("check-config", false, "Validate the configuration file and exit without listening") flag.Parse() if *showVersion { fmt.Printf("%s %s\n", version.Name, version.Version()) return } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) cfgPath := config.ConfigPath() if *checkConfig { if _, err := config.Load(cfgPath); err != nil { fmt.Fprintf(os.Stderr, "nuntius: configuration %s is invalid: %v\n", cfgPath, err) os.Exit(1) } fmt.Printf("configuration OK: %s\n", cfgPath) return } cfg, err := config.Load(cfgPath) if err != nil { logger.Error("config load failed", "path", cfgPath, "err", err) os.Exit(1) } h := handler.New(cfg) mux := http.NewServeMux() h.Register(mux) srv := &http.Server{ // The bind host and every timeout are configuration: an omitted // key resolves to the value this release has always used, so the // zero-config behaviour is unchanged. Addr: net.JoinHostPort(cfg.Server.Bind, strconv.Itoa(cfg.Server.Port)), Handler: withRequestLog(mux, logger, cfg.Server.TrustProxyHeaders), ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout(), ReadTimeout: cfg.Server.ReadTimeout(), WriteTimeout: cfg.Server.WriteTimeout(), IdleTimeout: cfg.Server.IdleTimeout(), } logger.Info("nuntius starting", "version", version.Name+" "+version.Version(), "addr", srv.Addr, "config", cfgPath, "forms", len(cfg.Forms), ) for _, f := range cfg.Forms { logger.Info("form registered", "name", f.Name, "path", f.Path, "to", f.To, "smtp", f.SMTP.Host, ) } // Graceful shutdown. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("server error", "err", err) os.Exit(1) } }() <-ctx.Done() logger.Info("shutdown signal received") shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout()) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("shutdown error", "err", err) h.PersistState() os.Exit(1) } h.PersistState() h.Close() logger.Info("nuntius stopped cleanly") } // withRequestLog logs each HTTP request method, path, status, and duration. // The logged IP follows the same trust rules as rate limiting: proxy // headers only when cfg says a trusted proxy is in front. func withRequestLog(next http.Handler, logger *slog.Logger, trustProxy bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rw, r) logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rw.status, "duration_ms", time.Since(start).Milliseconds(), "ip", handler.ClientIP(r, trustProxy), ) }) } type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) }