766 lines
31 KiB
Go
766 lines
31 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 (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sync"
|
|
"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/jmap"
|
|
"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}
|
|
jmapBackend := &jmap.Backend{DB: database, Mailstore: mstore, Notify: notifyBus, Cfg: cfg, Logger: toolbox.GetLogger("jmap"), Relay: relayer, SMTP: backend}
|
|
|
|
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() {
|
|
l, err := net.Listen("tcp", tlsServer.Addr)
|
|
if err != nil {
|
|
logger.Error("TLS SMTP listen: %v", err)
|
|
return
|
|
}
|
|
// TLS must wrap outermost, after abuseguard — go-smtp decides isTLS via a
|
|
// c.conn.(*tls.Conn) type assertion on whatever Accept() returns.
|
|
// abuseguard.GuardListener wraps conns in its own type, so TLS applied
|
|
// before it (tls.Listen, then wrap the result) hides the real *tls.Conn from
|
|
// go-smtp and makes it re-offer STARTTLS on an already-encrypted connection.
|
|
tlsListener := tls.NewListener(abuseguard.GuardListener(l, database, cfg, logger), smtpTLSConfig)
|
|
if err := tlsServer.Serve(tlsListener); 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("Server").Key("IMAP_PORT").MustInt(143)
|
|
imapTLSPort := cfg.Section("Server").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(imapserver.IdleTimeoutListener(abuseguard.GuardListener(l, database, cfg, logger), 30*time.Minute)); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
logger.Error("plain IMAP server: %v", err)
|
|
}
|
|
}()
|
|
go func() {
|
|
l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort))
|
|
if err != nil {
|
|
logger.Error("TLS IMAP listen: %v", err)
|
|
return
|
|
}
|
|
// TLS must wrap outermost, after abuseguard/idletimeout — go-imap decides
|
|
// LOGINDISABLED/STARTTLS via a c.conn.(*tls.Conn) type assertion on whatever
|
|
// Accept() returns. Wrapping abuseguard's and idletimeout's own conn types
|
|
// around an already-established tls.Conn (tls.Listen first) hides that from
|
|
// the library and makes every LOGIN fail with "TLS is required" even over an
|
|
// already-encrypted connection. See imapserver_tls_test.go.
|
|
guarded := imapserver.IdleTimeoutListener(abuseguard.GuardListener(l, database, cfg, logger), 30*time.Minute)
|
|
imapTLSListener := tls.NewListener(guarded, imapTLSConfig)
|
|
if err := tlsServer.Serve(imapTLSListener); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
logger.Error("TLS IMAP server: %v", err)
|
|
}
|
|
}()
|
|
return plainServer, tlsServer
|
|
}
|
|
|
|
// runJMAP starts the JMAP (RFC 8620/8621) listener on its own dedicated port —
|
|
// deliberately not sharing the webui's HTTPS port (an explicit choice, not a
|
|
// technical requirement: webHTTPSConfig below is reused as-is for the cert, same
|
|
// hot-reloading Let's Encrypt/self-signed cert every other TLS listener already
|
|
// gets). JMAP mandates TLS (RFC 8620 §1.7), so there's no plaintext variant here,
|
|
// unlike SMTP/IMAP. Starts independent of -web-only/-smtp-only — it only depends
|
|
// on the DB/mailstore, never on SMTP or IMAP being up.
|
|
runJMAP := func() *http.Server {
|
|
jmapPort := cfg.Section("Server").Key("JMAP_PORT").MustInt(8443)
|
|
addr := fmt.Sprintf("0.0.0.0:%d", jmapPort)
|
|
srv := &http.Server{Addr: addr, Handler: jmapBackend.Mux(), TLSConfig: webHTTPSConfig, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
|
|
go func() {
|
|
logger.Info("JMAP listening on :%d", jmapPort)
|
|
rawListener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
logger.Error("JMAP listen: %v", err)
|
|
return
|
|
}
|
|
// Same reordering as SMTP/IMAP's TLS listeners above, for the same reason:
|
|
// net/http's own c.(*tls.Conn) detection (r.TLS, HTTP/2 ALPN) needs
|
|
// Accept() to return a genuine *tls.Conn, so abuseguard wraps the raw TCP
|
|
// listener and TLS wraps outermost, not the other way around.
|
|
l := tls.NewListener(abuseguard.GuardListener(rawListener, database, cfg, logger), webHTTPSConfig)
|
|
if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
logger.Error("JMAP server: %v", err)
|
|
}
|
|
}()
|
|
return srv
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Prunes send/receive log history (and any attachments stored alongside it) older
|
|
// than [Mailstore] email_log_retention_days — off by default (0), matching the
|
|
// "blank/0 means manual/off" convention used by the backup schedule and DKIM
|
|
// rotation above, since esrv_email_logs otherwise grows forever with no cleanup at
|
|
// all. Checked hourly, same cadence as the backup job, for the same reason (cheap,
|
|
// and a live Settings change takes effect without a restart).
|
|
runLogRetention := func() {
|
|
check := func() {
|
|
days := cfg.Section("Mailstore").Key("email_log_retention_days").MustInt(0)
|
|
if days <= 0 {
|
|
return
|
|
}
|
|
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
|
paths, deleted, err := database.PruneEmailLogsOlderThan(cutoff)
|
|
if err != nil {
|
|
logger.Error("log retention: %v", err)
|
|
return
|
|
}
|
|
for _, p := range paths {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
|
logger.Error("log retention: remove attachment %s: %v", p, err)
|
|
}
|
|
}
|
|
if deleted > 0 {
|
|
logger.Info("log retention: pruned %d email log(s) older than %d days", deleted, days)
|
|
}
|
|
}
|
|
check()
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
check()
|
|
}
|
|
}
|
|
go runLogRetention()
|
|
|
|
// 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()
|
|
|
|
// Created once, up front, so runRelayQueueWorker (below) and waitForShutdown share
|
|
// the same signal — otherwise the worker would have no way to know shutdown had
|
|
// started and could keep kicking off fresh batches until the process is killed
|
|
// mid-delivery.
|
|
sigCtx, stopSignal := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stopSignal()
|
|
|
|
// Delivers messages accepted onto esrv_relay_queue by Session.Data (see
|
|
// internal/relay/queue.go) — a 5s tick keeps delivery prompt without polling too
|
|
// aggressively; 10 concurrent deliveries / 50 per tick are fixed, not config, same
|
|
// "keeps this simple" precedent as retrySchedule. Stops starting new batches once
|
|
// sigCtx fires; relayWorkerWG lets waitForShutdown wait for an already-in-progress
|
|
// batch to actually finish instead of killing it mid-delivery.
|
|
var relayWorkerWG sync.WaitGroup
|
|
runRelayQueueWorker := func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-sigCtx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
relayWorkerWG.Add(1)
|
|
relayer.ProcessQueueOnce(10, 50)
|
|
relayWorkerWG.Done()
|
|
}
|
|
}
|
|
}
|
|
go runRelayQueueWorker()
|
|
|
|
// 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, jmapSrv *http.Server) {
|
|
<-sigCtx.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)
|
|
}
|
|
if jmapSrv != nil {
|
|
jmapSrv.Shutdown(shutdownCtx)
|
|
}
|
|
|
|
relayDone := make(chan struct{})
|
|
go func() {
|
|
relayWorkerWG.Wait()
|
|
close(relayDone)
|
|
}()
|
|
select {
|
|
case <-relayDone:
|
|
case <-shutdownCtx.Done():
|
|
logger.Warning("shutdown: relay queue worker still delivering after the drain timeout, exiting anyway")
|
|
}
|
|
logger.Info("shutdown complete")
|
|
}
|
|
|
|
// JMAP_ENABLE defaults true (unlike this codebase's usual new-feature-off
|
|
// convention) — meant to be on out of the box. Started here, before either branch
|
|
// below, since JMAP has no SMTP/IMAP/web-server dependency at the protocol level.
|
|
var jmapServer *http.Server
|
|
if cfg.Section("Server").Key("JMAP_ENABLE").MustBool(true) {
|
|
jmapServer = runJMAP()
|
|
}
|
|
|
|
if *smtpOnly {
|
|
smtpPlain, smtpTLS := runSMTP()
|
|
imapPlain, imapTLS := runIMAP()
|
|
go runCertRenewal()
|
|
waitForShutdown(smtpPlain, smtpTLS, imapPlain, imapTLS, nil, nil, jmapServer)
|
|
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())
|
|
})
|
|
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
|
|
writeMetricsText(w, database)
|
|
})
|
|
// 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)
|
|
// ReadHeaderTimeout is the slowloris fix (bounds only the time to receive headers);
|
|
// ReadTimeout/WriteTimeout are deliberately left unset — webmailMux serves an
|
|
// SSE stream (webmail_mail.go's event-stream handler) and attachment downloads,
|
|
// both legitimately long-lived, and a global deadline would cut them off.
|
|
httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: webHTTPSConfig, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
|
|
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, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
|
|
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, jmapServer)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// writeMetricsText serves a hand-written Prometheus text-exposition response (no
|
|
// client_golang dependency needed for this small, fixed set of gauges) reusing the
|
|
// same DB queries the admin dashboard already runs, so this doesn't add new query
|
|
// load beyond what /health and the dashboard already cause.
|
|
func writeMetricsText(w http.ResponseWriter, database *db.DB) {
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
|
|
|
dbUp := 1
|
|
if err := database.Ping(); err != nil {
|
|
dbUp = 0
|
|
}
|
|
fmt.Fprintf(w, "# HELP mailgoserver_db_up Whether the database connection is healthy (1) or not (0).\n")
|
|
fmt.Fprintf(w, "# TYPE mailgoserver_db_up gauge\n")
|
|
fmt.Fprintf(w, "mailgoserver_db_up %d\n", dbUp)
|
|
|
|
if pending, err := database.CountPendingRelayQueueItems(); err == nil {
|
|
fmt.Fprintf(w, "# HELP mailgoserver_relay_queue_pending Outbound relay domain-groups still awaiting delivery.\n")
|
|
fmt.Fprintf(w, "# TYPE mailgoserver_relay_queue_pending gauge\n")
|
|
fmt.Fprintf(w, "mailgoserver_relay_queue_pending %d\n", pending)
|
|
}
|
|
|
|
if success, failed, err := database.DeliveryStats(24); err == nil {
|
|
fmt.Fprintf(w, "# HELP mailgoserver_delivery_success_24h Successful recipient deliveries in the last 24h.\n")
|
|
fmt.Fprintf(w, "# TYPE mailgoserver_delivery_success_24h gauge\n")
|
|
fmt.Fprintf(w, "mailgoserver_delivery_success_24h %d\n", success)
|
|
fmt.Fprintf(w, "# HELP mailgoserver_delivery_failed_24h Failed recipient deliveries in the last 24h.\n")
|
|
fmt.Fprintf(w, "# TYPE mailgoserver_delivery_failed_24h gauge\n")
|
|
fmt.Fprintf(w, "mailgoserver_delivery_failed_24h %d\n", failed)
|
|
}
|
|
}
|