// 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 ( "context" "crypto/tls" "errors" "flag" "fmt" "io" "net" "net/http" "os" "os/signal" "path/filepath" "sync/atomic" "syscall" "time" goimapserver "github.com/emersion/go-imap/v2/imapserver" "github.com/emersion/go-smtp" "mailgoserver/internal/abuseguard" "mailgoserver/internal/acmecert" "mailgoserver/internal/backup" "mailgoserver/internal/config" "mailgoserver/internal/db" "mailgoserver/internal/dkim" "mailgoserver/internal/dnspublish" "mailgoserver/internal/imapserver" "mailgoserver/internal/mailstore" "mailgoserver/internal/notify" "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") // Defaults inside server_data/ (along with the DB, mailstore, keys, and certs // generated below) so a Docker deployment only needs to volume-mount that one // directory to persist everything — nothing is left sitting next to the binary. configFlag := flag.String("config", "server_data/settings.ini", "Configuration file path") backupServer := flag.Bool("backup-server", false, "Write a whole-server backup archive to -out and exit") restoreServer := flag.Bool("restore-server", false, "Restore a whole-server backup archive from -in and exit") backupOut := flag.String("out", "-", "Backup: output archive path (\"-\" for stdout)") backupIn := flag.String("in", "-", "Restore: input archive path (\"-\" for stdin)") backupPassphrase := flag.String("backup-passphrase", "", "Backup: optional passphrase to encrypt the archive") restorePassphrase := flag.String("restore-passphrase", "", "Restore: passphrase, required if the archive was encrypted") forceRestore := flag.Bool("force", false, "Restore: overwrite a non-empty server_data directory") flag.Parse() portFlagSet := false flag.Visit(func(f *flag.Flag) { if f.Name == "port" { portFlagSet = true } }) 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) } // server_data/ holds the DB, encrypted mailstore, and master key — see // internal/backup's doc comment. Restore runs before config.Load (a few lines // below) touches server_data at all — config.Load auto-creates a default // settings.ini on first run, which would otherwise make a truly empty target // directory look non-empty by the time restore's own check ran, forcing -force // even on a fresh directory. Restore replaces these files wholesale; run it, then // restart the server normally (or let the container restart) to pick up the // restored data. dataDir := absPath(root, "server_data") if *restoreServer { in := io.Reader(os.Stdin) if *backupIn != "-" { f, err := os.Open(*backupIn) if err != nil { fmt.Fprintln(os.Stderr, "open backup file:", err) os.Exit(1) } defer f.Close() in = f } if err := backup.RestoreServer(in, dataDir, *restorePassphrase, *forceRestore); err != nil { fmt.Fprintln(os.Stderr, "restore:", err) os.Exit(1) } fmt.Println("Restore complete:", dataDir) return } 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) } if *backupServer { out := io.Writer(os.Stdout) if *backupOut != "-" { f, err := os.Create(*backupOut) if err != nil { logger.Error("create backup file: %v", err) os.Exit(1) } defer f.Close() out = f } if err := backup.WriteServerConsistent(database, out, dataDir, dbPath, *backupPassphrase); err != nil { logger.Error("backup: %v", err) os.Exit(1) } logger.Info("Backup complete") return } 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) } mailstoreBase := absPath(root, cfg.Section("Mailstore").Key("mailstore_path").MustString("server_data/mailstore")) masterKeyPath := absPath(root, cfg.Section("Mailstore").Key("master_key_path").MustString("server_data/mailstore_master.key")) masterKey, err := mailstore.LoadOrCreateMasterKey(masterKeyPath) if err != nil { logger.Error("load mailstore master key: %v", err) os.Exit(1) } mstore := mailstore.New(database, masterKey, mailstoreBase) relayer.Mailstore = mstore // Three independent certificate "slots" — custom (self-signed by default, or your // own uploaded cert/key), letsencrypt_dns, letsencrypt_http — each with its own // file pair and CertReloader. [TLS]'s smtp_tls_cert/imap_tls_cert/web_https_cert // independently pick which slot each listener uses, e.g. an HTTP-01 cert for mail // while a DNS-01 cert serves the dashboard. Every slot is seeded with a self-signed // cert if missing, so a reloader can always be constructed even before any ACME // manager has obtained anything yet. customCertFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String()) customKeyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String()) dnsCertFile := absPath(root, "server_data/ssl_certs/letsencrypt_dns.crt") dnsKeyFile := absPath(root, "server_data/ssl_certs/letsencrypt_dns.key") httpCertFile := absPath(root, "server_data/ssl_certs/letsencrypt_http.crt") httpKeyFile := absPath(root, "server_data/ssl_certs/letsencrypt_http.key") for _, pair := range [][2]string{ {customCertFile, customKeyFile}, {dnsCertFile, dnsKeyFile}, {httpCertFile, httpKeyFile}, } { if err := tlsutil.GenerateSelfSignedCert(pair[0], pair[1]); err != nil { logger.Error("generate TLS certificate: %v", err) os.Exit(1) } } customReloader, err := tlsutil.NewCertReloader(customCertFile, customKeyFile) if err != nil { logger.Error("load TLS certificate: %v", err) os.Exit(1) } dnsReloader, err := tlsutil.NewCertReloader(dnsCertFile, dnsKeyFile) if err != nil { logger.Error("load TLS certificate: %v", err) os.Exit(1) } httpReloader, err := tlsutil.NewCertReloader(httpCertFile, httpKeyFile) if err != nil { logger.Error("load TLS certificate: %v", err) os.Exit(1) } certSlots := map[string]*tlsutil.CertReloader{ "custom": customReloader, "letsencrypt_dns": dnsReloader, "letsencrypt_http": httpReloader, } resolveCertSlot := func(key string) *tlsutil.CertReloader { if r, ok := certSlots[cfg.Section("TLS").Key(key).MustString("custom")]; ok { return r } return customReloader } smtpTLSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("smtp_tls_cert")) imapTLSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("imap_tls_cert")) webHTTPSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("web_https_cert")) acmeDataDir := absPath(root, "server_data/acme") acmeMgr := acmecert.New(cfg, "LetsEncrypt", "dns-01", dnsCertFile, dnsKeyFile, acmeDataDir, dnsReloader, toolbox.GetLogger("acme")) acmeHTTPMgr := acmecert.New(cfg, "LetsEncryptHTTP", "http-01", httpCertFile, httpKeyFile, acmeDataDir, httpReloader, toolbox.GetLogger("acme-http")) // The HTTP-01 challenge responder is long-lived (unlike lego's own ephemeral // per-obtain listener) so an operator behind NAT/a reverse proxy can verify their // port-forwarding actually reaches this host before/without triggering a real, // rate-limited ACME attempt — curling it should return 200 once this is up. // Started once at boot if enabled; toggling [LetsEncryptHTTP] enabled needs a // restart to start or stop this listener, same as the *_cert routing settings. if cfg.Section("LetsEncryptHTTP").Key("enabled").MustBool(false) { httpChallengeServer := acmecert.NewHTTP01Server() httpPort := cfg.Section("Server").Key("HTTP_LETSENCRYPT_PORT").MustString("80") httpChallengeServer.Start(fmt.Sprintf(":%s", httpPort), toolbox.GetLogger("acme-http")) acmeHTTPMgr.HTTP01Server = httpChallengeServer logger.Info("HTTP-01 challenge responder listening on :%s", httpPort) } // Shared by SMTP local delivery, IMAP APPEND (both publish), IMAP IDLE, and // webmail's SSE push (both subscribe) — see internal/notify's doc comment. notifyBus := notify.NewBus() backend := &smtpserver.Backend{ DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg, Mailstore: mstore, Logger: toolbox.GetLogger("smtp"), HeloHostname: heloHostname, AttachmentsBasePath: attachmentsBase, Notify: notifyBus, } imapBackend := &imapserver.Backend{DB: database, Mailstore: mstore, Logger: toolbox.GetLogger("imap"), Cfg: cfg, Notify: notifyBus} var smtpRunning atomic.Bool // runSMTP/runIMAP return their constructed server handles immediately (both // listeners are always started in their own goroutine — previously the TLS one // blocked inside the function body, which didn't matter since every caller already // wrapped the whole call in "go runSMTP()"/"go runIMAP()", but returning now lets // main hold onto the handles for graceful shutdown). runSMTP := func() (plain, tlsSrv *smtp.Server) { smtpPort := cfg.Section("Server").Key("SMTP_PORT").MustInt(25) smtpTLSPort := cfg.Section("Server").Key("SMTP_TLS_PORT").MustInt(465) banner := smtpserver.ResolveBanner(cfg, heloHostname) // 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, smtpTLSConfig) smtpRunning.Store(true) logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort) // Listeners are built explicitly (rather than via ListenAndServe/ListenAndServeTLS) // so abuseguard can reject blacklisted IPs before the SMTP banner is ever sent. go func() { l, err := net.Listen("tcp", plainServer.Addr) if err != nil { logger.Error("plain SMTP listen: %v", err) return } if err := plainServer.Serve(abuseguard.GuardListener(l, database, cfg, logger)); err != nil && !errors.Is(err, smtp.ErrServerClosed) { logger.Error("plain SMTP server: %v", err) } }() go func() { tlsListener, err := tls.Listen("tcp", tlsServer.Addr, smtpTLSConfig) if err != nil { logger.Error("TLS SMTP listen: %v", err) return } if err := tlsServer.Serve(abuseguard.GuardListener(tlsListener, database, cfg, logger)); err != nil && !errors.Is(err, smtp.ErrServerClosed) { logger.Error("TLS SMTP server: %v", err) } }() return plainServer, tlsServer } runIMAP := func() (plain, tlsSrv *goimapserver.Server) { imapPort := cfg.Section("IMAP").Key("IMAP_PORT").MustInt(143) imapTLSPort := cfg.Section("IMAP").Key("IMAP_TLS_PORT").MustInt(993) plainServer := imapserver.NewPlainServer(imapBackend) tlsServer := imapserver.NewTLSServer(imapBackend, imapTLSConfig) logger.Info("Plain IMAP listening on :%d, direct-TLS IMAP listening on :%d", imapPort, imapTLSPort) // Listeners are built explicitly so abuseguard can reject blacklisted IPs before // the IMAP greeting is ever sent — same reasoning as runSMTP above. go func() { l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapPort)) if err != nil { logger.Error("plain IMAP listen: %v", err) return } if err := plainServer.Serve(abuseguard.GuardListener(l, database, cfg, logger)); err != nil && !errors.Is(err, net.ErrClosed) { logger.Error("plain IMAP server: %v", err) } }() go func() { imapTLSListener, err := tls.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort), imapTLSConfig) if err != nil { logger.Error("TLS IMAP listen: %v", err) return } if err := tlsServer.Serve(abuseguard.GuardListener(imapTLSListener, database, cfg, logger)); err != nil && !errors.Is(err, net.ErrClosed) { logger.Error("TLS IMAP server: %v", err) } }() return plainServer, tlsServer } // runCertRenewal is the first periodic/background job in this codebase — everything // else is purely request-driven. Checks soon after boot (so enabling Let's Encrypt // and restarting converges quickly) and every 12h thereafter. NeedsRenewal is a pure // disk-state check (it reads the cert file itself, including recognizing the // self-signed placeholder by its issuer) — deliberately not gated on any in-memory // "has this process attempted yet" flag, so restarting an already-working setup // never triggers a redundant re-obtain of an already-valid real certificate. runCertRenewal := func() { time.Sleep(1 * time.Minute) checkAndRenewOne := func(mgr *acmecert.Manager) { if !mgr.Enabled() { return } if needs, err := mgr.NeedsRenewal(); err != nil || !needs { return } if err := mgr.ObtainOrRenew(context.Background()); err != nil { logger.Error("ACME obtain/renew (%s): %v", mgr.Section, err) } } checkAndRenew := func() { checkAndRenewOne(acmeMgr) checkAndRenewOne(acmeHTTPMgr) } checkAndRenew() ticker := time.NewTicker(12 * time.Hour) defer ticker.Stop() for range ticker.C { checkAndRenew() } } // Scheduled whole-server backups (internal/backup's [Backup] section) — off by // default (schedule == ""). Checked hourly (coarse enough for daily/weekly/monthly // intervals, cheap enough not to matter) so a schedule/dir/keep change made on the // live Settings page takes effect without a restart, same as runCertRenewal above. // Runs in every mode (including -web-only and -smtp-only), started once below. runScheduledBackups := func() { check := func() { schedule := cfg.Section("Backup").Key("schedule").String() interval, ok := backup.IntervalForSchedule(schedule) if !ok { return } backupDir := absPath(root, cfg.Section("Backup").Key("dir").MustString("server_data/backups")) keep := cfg.Section("Backup").Key("keep").MustInt(3) path, err := backup.RunScheduledIfDue(database, dataDir, dbPath, backupDir, keep, interval) if err != nil { logger.Error("scheduled backup: %v", err) } else if path != "" { logger.Info("scheduled backup written: %s", path) } } check() ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { check() } } go runScheduledBackups() // Optional auto-rotation of the shared/global DKIM key (CNAME delegation) — // manual-only (no ticker work beyond the no-op check) when [DKIM] // global_dkim_rotation_days is blank/0, same "blank means manual" convention as // the scheduled-backup interval above. No prior key counts as due, same as // backup.RunScheduledIfDue's own "no prior backup" convention, so setting a // schedule with no key generated yet creates the first one immediately. runGlobalDKIMRotation := func() { check := func() { sec := cfg.Section("DKIM") days := sec.Key("global_dkim_rotation_days").MustInt(0) if days <= 0 { return } due := true if key, err := dkimMgr.GetActiveGlobalDKIMKey(); err == nil && key != nil { due = time.Since(key.CreatedAt) >= time.Duration(days)*24*time.Hour } if !due { return } hostname := sec.Key("global_dkim_hostname").String() var creds *dnspublish.Credentials if provider := sec.Key("global_dkim_provider").String(); provider != "" { creds = &dnspublish.Credentials{ Provider: provider, ZoneName: hostname, CloudflareAPIToken: sec.Key("global_dkim_cloudflare_api_token").String(), Route53AccessKeyID: sec.Key("global_dkim_route53_access_key_id").String(), Route53SecretAccessKey: sec.Key("global_dkim_route53_secret_access_key").String(), Route53Region: sec.Key("global_dkim_route53_region").String(), DigitalOceanAPIToken: sec.Key("global_dkim_digitalocean_api_token").String(), GCloudProject: sec.Key("global_dkim_gcloud_project").String(), GCloudServiceAccountJSON: sec.Key("global_dkim_gcloud_service_account_json").String(), } } publishErr, err := dkimMgr.GenerateGlobalDKIMKey(hostname, creds) if err != nil { logger.Error("scheduled global DKIM rotation: %v", err) } else if publishErr != nil { logger.Error("scheduled global DKIM rotation: publish failed: %v", publishErr) } else { logger.Info("scheduled global DKIM key rotated") } } check() ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { check() } } go runGlobalDKIMRotation() // Shared by both the -smtp-only branch below and the full-server path at the // bottom of main: waits for SIGINT/SIGTERM, then shuts down whatever server // handles it's given (nil-safe — a branch that never started a given server just // passes nil for it) within a bounded timeout so a stuck connection can't hang a // restart/redeploy forever. go-smtp's Server has a real graceful Shutdown(ctx); // go-imap/v2's Server only has Close() (force-close, confirmed no graceful variant // exists in that library) — still better than no shutdown handling at all. waitForShutdown := func(smtpPlain, smtpTLS *smtp.Server, imapPlain, imapTLS *goimapserver.Server, httpSrv, httpsSrv *http.Server) { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() <-ctx.Done() logger.Info("shutdown signal received, draining connections...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() if smtpPlain != nil { smtpPlain.Shutdown(shutdownCtx) } if smtpTLS != nil { smtpTLS.Shutdown(shutdownCtx) } if imapPlain != nil { imapPlain.Close() } if imapTLS != nil { imapTLS.Close() } if httpSrv != nil { httpSrv.Shutdown(shutdownCtx) } if httpsSrv != nil { httpsSrv.Shutdown(shutdownCtx) } logger.Info("shutdown complete") } if *smtpOnly { smtpPlain, smtpTLS := runSMTP() imapPlain, imapTLS := runIMAP() go runCertRenewal() waitForShutdown(smtpPlain, smtpTLS, imapPlain, imapTLS, nil, nil) return } appSecretPath := absPath(root, cfg.Section("Security").Key("app_secret_path").MustString("server_data/app_secret.key")) appSecret, err := webui.LoadOrCreateAppSecret(appSecretPath) if err != nil { logger.Error("load app secret: %v", err) os.Exit(1) } app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, root, toolbox.GetLogger("web"), smtpRunning.Load, appSecret) if err != nil { logger.Error("init web UI: %v", err) os.Exit(1) } app.Notify = notifyBus mux := app.Mux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { writeHealthJSON(w, database, smtpRunning.Load()) }) // Most visitors are mailbox owners, not admins — default the bare root to the // self-service webmail login, with a "Login as Admin" button there for the admin // dashboard's login instead of requiring the admin URL to be typed by hand. mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, webui.MailboxPrefix+"/login", http.StatusFound) }) var smtpPlain, smtpTLS *smtp.Server var imapPlain, imapTLS *goimapserver.Server if !*webOnly { smtpPlain, smtpTLS = runSMTP() imapPlain, imapTLS = runIMAP() go runCertRenewal() time.Sleep(500 * time.Millisecond) } // Every request (admin, webmail, login, static, /health) funnels through mux, so // wrapping it once here — rather than at each of webui.Mux()'s internal // registration points — is the single correct place for cross-cutting middleware // that has to apply uniformly across the whole app. handler := webui.SecurityHeaders(app.CSRFProtect(mux)) httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001) httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort) httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: webHTTPSConfig} go func() { logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr) // Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies // the certificate, so ListenAndServeTLS never reads from disk itself — the same // self-signed-by-default, Let's-Encrypt-when-enabled cert the SMTP/IMAP TLS // listeners already use, hot-reloaded without restarting this server either. if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("web https server: %v", err) } }() httpPort := *port if !portFlagSet { httpPort = cfg.Section("Server").Key("WEB_HTTP_PORT").MustInt(5000) } addr := fmt.Sprintf("%s:%d", *host, httpPort) httpServer := &http.Server{Addr: addr, Handler: handler} go func() { logger.Info("Web interface starting at http://%s (debug=%v)", addr, *debug) if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("web server: %v", err) } }() waitForShutdown(smtpPlain, smtpTLS, imapPlain, imapTLS, httpServer, httpsServer) } 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) }