Files
mailgoserver/main.go
T
2026-08-12 12:56:22 +01:00

191 lines
5.9 KiB
Go

// Command mailgoserver is the Go port of PyMTA-server: an async SMTP MTA (direct-to-MX
// delivery, DKIM signing, IP/user auth) plus its admin web UI, mirroring app.py.
package main
import (
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"time"
"mailgoserver/internal/config"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/relay"
"mailgoserver/internal/smtpserver"
"mailgoserver/internal/tlsutil"
"mailgoserver/internal/toolbox"
"mailgoserver/internal/webui"
)
func main() {
smtpOnly := flag.Bool("smtp-only", false, "Run SMTP server only")
webOnly := flag.Bool("web-only", false, "Run web frontend only")
host := flag.String("host", "127.0.0.1", "Web server host")
port := flag.Int("port", 5000, "Web server port")
debug := flag.Bool("debug", false, "Enable debug mode")
initData := flag.Bool("init-data", false, "Initialize sample data and exit")
configFlag := flag.String("config", "settings.ini", "Configuration file path")
flag.Parse()
root, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
configPath := *configFlag
if !filepath.IsAbs(configPath) {
configPath = filepath.Join(root, configPath)
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, "load settings:", err)
os.Exit(1)
}
toolbox.Configure(cfg)
logger := toolbox.GetLogger("main")
if *initData {
// Mirrors app.py's --init-data: the actual seeding call is commented out
// there too, so this flag currently only logs and exits without touching
// the DB — preserved as-is rather than silently making it do more.
logger.Info("Initializing sample data...")
logger.Info("Sample data initialization complete")
return
}
dbPath := config.AbsoluteSQLitePath(cfg.Section("Database").Key("DATABASE_URL").String(), root)
if err := toolbox.EnsureFolderExists(dbPath); err != nil {
logger.Error("create database directory: %v", err)
os.Exit(1)
}
database, err := db.Open(dbPath)
if err != nil {
logger.Error("open database: %v", err)
os.Exit(1)
}
defer database.Close()
if err := database.SeedDefaultAdminIfEmpty(); err != nil {
logger.Error("seed default admin: %v", err)
os.Exit(1)
}
dkimKeySize := cfg.Section("DKIM").Key("DKIM_KEY_SIZE").MustInt(2048)
dkimMgr := dkim.New(database, dkimKeySize)
relayer := relay.New(database, cfg, toolbox.GetLogger("relay"))
heloHostname := cfg.Section("Server").Key("helo_hostname").String()
if heloHostname == "" {
heloHostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
attachmentsBase := cfg.Section("Attachments").Key("attachments_path").String()
if !filepath.IsAbs(attachmentsBase) {
attachmentsBase = filepath.Join(root, attachmentsBase)
}
backend := &smtpserver.Backend{
DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg,
Logger: toolbox.GetLogger("smtp"), HeloHostname: heloHostname, AttachmentsBasePath: attachmentsBase,
}
var smtpRunning atomic.Bool
runSMTP := func() {
smtpPort := cfg.Section("Server").Key("SMTP_PORT").MustInt(4025)
smtpTLSPort := cfg.Section("Server").Key("SMTP_TLS_PORT").MustInt(40465)
banner := smtpserver.ResolveBanner(cfg, heloHostname)
certFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String())
keyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String())
if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil {
logger.Error("generate TLS certificate: %v", err)
return
}
tlsConfig, err := tlsutil.CreateSSLContext(certFile, keyFile)
if err != nil {
logger.Error("create TLS context: %v", err)
return
}
// Bind IPv4-only ("0.0.0.0"), matching both listeners in server_runner.py
// exactly (they hardcode hostname='0.0.0.0' regardless of the BIND_IP
// setting) — a bare ":port" address binds dual-stack on most systems,
// which would accept IPv6 connections the Python version never did.
plainServer := smtpserver.NewPlainServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpPort), banner)
tlsServer := smtpserver.NewTLSServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpTLSPort), banner, tlsConfig)
smtpRunning.Store(true)
logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort)
go func() {
if err := plainServer.ListenAndServe(); err != nil {
logger.Error("plain SMTP server: %v", err)
}
}()
if err := tlsServer.ListenAndServeTLS(); err != nil {
logger.Error("TLS SMTP server: %v", err)
}
}
if *smtpOnly {
runSMTP()
return
}
app, err := webui.New(database, dkimMgr, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load)
if err != nil {
logger.Error("init web UI: %v", err)
os.Exit(1)
}
mux := app.Mux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeHealthJSON(w, database, smtpRunning.Load())
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, webui.Prefix+"/", http.StatusFound)
})
if !*webOnly {
go runSMTP()
time.Sleep(500 * time.Millisecond)
}
addr := fmt.Sprintf("%s:%d", *host, *port)
logger.Info("Web interface starting at http://%s (debug=%v)", addr, *debug)
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("web server: %v", err)
os.Exit(1)
}
}
func absPath(root, p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(root, p)
}
func writeHealthJSON(w http.ResponseWriter, database *db.DB, smtpUp bool) {
dbStatus := "ok"
if err := database.Ping(); err != nil {
dbStatus = "error"
}
smtpStatus := "stopped"
if smtpUp {
smtpStatus = "running"
}
overall := "healthy"
if smtpStatus == "stopped" || dbStatus == "error" {
overall = "degraded"
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":%q,"timestamp":%q,"services":{"smtp_server":%q,"web_frontend":"running","database":%q},"version":"1.0.0"}`,
overall, time.Now().Format(time.RFC3339), smtpStatus, dbStatus)
}